Add DTO, exceptions, Flyway config, JPA file and test that allows user registration.
This commit is contained in:
@@ -0,0 +1,71 @@
|
||||
/**
|
||||
* AuthController.java
|
||||
*
|
||||
* Controls REST messages starting with /auth.
|
||||
*/
|
||||
package com.seanstarkey.notesvault.auth;
|
||||
|
||||
import com.seanstarkey.notesvault.dto.RegisterRequest;
|
||||
import com.seanstarkey.notesvault.dto.UserResponse;
|
||||
import com.seanstarkey.notesvault.entity.User;
|
||||
import com.seanstarkey.notesvault.exception.ConflictException;
|
||||
import com.seanstarkey.notesvault.repository.UserRepository;
|
||||
import jakarta.validation.Valid;
|
||||
import org.springframework.dao.DataIntegrityViolationException;
|
||||
import org.springframework.http.HttpStatus;
|
||||
import org.springframework.http.ResponseEntity;
|
||||
import org.springframework.security.crypto.password.PasswordEncoder;
|
||||
import org.springframework.transaction.annotation.Transactional;
|
||||
import org.springframework.web.bind.annotation.PostMapping;
|
||||
import org.springframework.web.bind.annotation.RequestBody;
|
||||
import org.springframework.web.bind.annotation.RequestMapping;
|
||||
import org.springframework.web.bind.annotation.RestController;
|
||||
|
||||
@RestController
|
||||
@RequestMapping("/auth")
|
||||
public class AuthController {
|
||||
|
||||
private final UserRepository userRepository;
|
||||
private final PasswordEncoder passwordEncoder;
|
||||
|
||||
/**
|
||||
* @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) {
|
||||
this.userRepository = userRepository;
|
||||
this.passwordEncoder = passwordEncoder;
|
||||
}
|
||||
|
||||
/**
|
||||
* Registers a new user account with a unique username and BCrypt-hashed password.
|
||||
*
|
||||
* @param request validated registration payload containing username and password
|
||||
* @return 201 Created with the public user representation in the response body
|
||||
* @throws ConflictException when the requested username is already registered
|
||||
*/
|
||||
@PostMapping("/register")
|
||||
@Transactional
|
||||
public ResponseEntity<UserResponse> register(@Valid @RequestBody RegisterRequest request) {
|
||||
if (userRepository.existsByUsername(request.username())) {
|
||||
throw new ConflictException("Username is already registered");
|
||||
}
|
||||
|
||||
User user = new User();
|
||||
user.setUsername(request.username());
|
||||
user.setPasswordHash(passwordEncoder.encode(request.password()));
|
||||
|
||||
try {
|
||||
User savedUser = userRepository.saveAndFlush(user);
|
||||
return ResponseEntity
|
||||
.status(HttpStatus.CREATED)
|
||||
.body(toResponse(savedUser));
|
||||
} catch (DataIntegrityViolationException ex) {
|
||||
throw new ConflictException("Username is already registered", ex);
|
||||
}
|
||||
}
|
||||
|
||||
private UserResponse toResponse(User user) {
|
||||
return new UserResponse(user.getId(), user.getUsername(), user.getCreatedAt());
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,44 @@
|
||||
/**
|
||||
* SecurityConfig.java
|
||||
*/
|
||||
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.config.annotation.web.builders.HttpSecurity;
|
||||
import org.springframework.security.config.http.SessionCreationPolicy;
|
||||
import org.springframework.security.crypto.bcrypt.BCryptPasswordEncoder;
|
||||
import org.springframework.security.crypto.password.PasswordEncoder;
|
||||
import org.springframework.security.web.SecurityFilterChain;
|
||||
|
||||
/**
|
||||
* Provides the password encoder bean used to hash and verify user passwords.
|
||||
*/
|
||||
@Configuration
|
||||
public class SecurityConfig {
|
||||
|
||||
@Bean
|
||||
public SecurityFilterChain securityFilterChain(HttpSecurity http) throws Exception {
|
||||
return http
|
||||
.csrf(csrf -> csrf.disable())
|
||||
.authorizeHttpRequests(auth -> auth
|
||||
.requestMatchers(HttpMethod.POST, "/auth/register").permitAll()
|
||||
.anyRequest().authenticated()
|
||||
)
|
||||
.sessionManagement(session -> session
|
||||
.sessionCreationPolicy(SessionCreationPolicy.STATELESS)
|
||||
)
|
||||
.build();
|
||||
}
|
||||
|
||||
/**
|
||||
* Creates the BCrypt-backed password encoder.
|
||||
*
|
||||
* @return password encoder for BCrypt hashes
|
||||
*/
|
||||
@Bean
|
||||
public PasswordEncoder passwordEncoder() {
|
||||
return new BCryptPasswordEncoder();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,22 @@
|
||||
/**
|
||||
* AuthResponse.java
|
||||
*
|
||||
* Response DTO returned after successful authentication.
|
||||
*/
|
||||
package com.seanstarkey.notesvault.dto;
|
||||
|
||||
import java.time.Instant;
|
||||
|
||||
/**
|
||||
* @param token signed JWT string to send in the Authorization header
|
||||
* @param tokenType token scheme; expected to be {@code Bearer}
|
||||
* @param expiresInSeconds number of seconds until the token expires
|
||||
* @param expiresAt absolute expiration timestamp
|
||||
*/
|
||||
public record AuthResponse(
|
||||
String token,
|
||||
String tokenType,
|
||||
long expiresInSeconds,
|
||||
Instant expiresAt
|
||||
) {
|
||||
}
|
||||
@@ -0,0 +1,13 @@
|
||||
/**
|
||||
* ErrorResponse.java
|
||||
*
|
||||
* Response DTO for API errors that can be represented by a single human-readable
|
||||
* message.
|
||||
*/
|
||||
package com.seanstarkey.notesvault.dto;
|
||||
|
||||
/**
|
||||
* @param error human-readable error message
|
||||
*/
|
||||
public record ErrorResponse(String error) {
|
||||
}
|
||||
@@ -0,0 +1,24 @@
|
||||
/**
|
||||
* LoginRequest.java
|
||||
*
|
||||
* Request DTO for authenticating an existing user.
|
||||
*/
|
||||
package com.seanstarkey.notesvault.dto;
|
||||
|
||||
import jakarta.validation.constraints.NotBlank;
|
||||
import jakarta.validation.constraints.Size;
|
||||
|
||||
/**
|
||||
* @param username login name for the account
|
||||
* @param password plaintext password to verify against the stored bcrypt hash
|
||||
*/
|
||||
public record LoginRequest(
|
||||
@NotBlank
|
||||
@Size(min = 3, max = 50)
|
||||
String username,
|
||||
|
||||
@NotBlank
|
||||
@Size(min = 8)
|
||||
String password
|
||||
) {
|
||||
}
|
||||
@@ -0,0 +1,24 @@
|
||||
/**
|
||||
* RegisterRequest.java
|
||||
*
|
||||
* Request DTO for creating a new Secure Notes Vault user account.
|
||||
*/
|
||||
package com.seanstarkey.notesvault.dto;
|
||||
|
||||
import jakarta.validation.constraints.NotBlank;
|
||||
import jakarta.validation.constraints.Size;
|
||||
|
||||
/**
|
||||
* @param username unique login name requested by the caller
|
||||
* @param password plaintext password to hash before persistence
|
||||
*/
|
||||
public record RegisterRequest(
|
||||
@NotBlank
|
||||
@Size(min = 3, max = 50)
|
||||
String username,
|
||||
|
||||
@NotBlank
|
||||
@Size(min = 8)
|
||||
String password
|
||||
) {
|
||||
}
|
||||
@@ -0,0 +1,21 @@
|
||||
/**
|
||||
* UserResponse.java
|
||||
*
|
||||
* Response DTO for user account data safe to return through the API.
|
||||
*/
|
||||
package com.seanstarkey.notesvault.dto;
|
||||
|
||||
import java.time.Instant;
|
||||
import java.util.UUID;
|
||||
|
||||
/**
|
||||
* @param id stable user identifier
|
||||
* @param username unique login name
|
||||
* @param createdAt timestamp when the account was created
|
||||
*/
|
||||
public record UserResponse(
|
||||
UUID id,
|
||||
String username,
|
||||
Instant createdAt
|
||||
) {
|
||||
}
|
||||
@@ -0,0 +1,19 @@
|
||||
/**
|
||||
* ValidationErrorResponse.java
|
||||
*
|
||||
* Response DTO for Jakarta Bean Validation failures. Captures both a summary
|
||||
* message and field-specific validation details.
|
||||
*/
|
||||
package com.seanstarkey.notesvault.dto;
|
||||
|
||||
import java.util.Map;
|
||||
|
||||
/**
|
||||
* @param error summary error message
|
||||
* @param fields map of field names to validation messages
|
||||
*/
|
||||
public record ValidationErrorResponse(
|
||||
String error,
|
||||
Map<String, String> fields
|
||||
) {
|
||||
}
|
||||
@@ -0,0 +1,25 @@
|
||||
/**
|
||||
* ConflictException.java
|
||||
*/
|
||||
package com.seanstarkey.notesvault.exception;
|
||||
|
||||
/**
|
||||
* Signals an HTTP 409 Conflict condition for domain or persistence uniqueness failures.
|
||||
*/
|
||||
public class ConflictException extends RuntimeException {
|
||||
|
||||
/**
|
||||
* @param message human-readable description of the conflict
|
||||
*/
|
||||
public ConflictException(String message) {
|
||||
super(message);
|
||||
}
|
||||
|
||||
/**
|
||||
* @param message human-readable description of the conflict
|
||||
* @param cause lower-level exception that triggered the conflict
|
||||
*/
|
||||
public ConflictException(String message, Throwable cause) {
|
||||
super(message, cause);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,17 @@
|
||||
/**
|
||||
* ForbiddenException.java
|
||||
*/
|
||||
package com.seanstarkey.notesvault.exception;
|
||||
|
||||
/**
|
||||
* Signals an HTTP 403 Forbidden condition for authenticated authorization failures.
|
||||
*/
|
||||
public class ForbiddenException extends RuntimeException {
|
||||
|
||||
/**
|
||||
* @param message human-readable description of the authorization failure
|
||||
*/
|
||||
public ForbiddenException(String message) {
|
||||
super(message);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,120 @@
|
||||
/**
|
||||
* GlobalExceptionHandler.java
|
||||
*/
|
||||
package com.seanstarkey.notesvault.exception;
|
||||
|
||||
import com.seanstarkey.notesvault.dto.ErrorResponse;
|
||||
import com.seanstarkey.notesvault.dto.ValidationErrorResponse;
|
||||
import org.springframework.http.HttpStatus;
|
||||
import org.springframework.http.ResponseEntity;
|
||||
import org.springframework.http.converter.HttpMessageNotReadableException;
|
||||
import org.springframework.security.core.AuthenticationException;
|
||||
import org.springframework.web.bind.MethodArgumentNotValidException;
|
||||
import org.springframework.web.bind.annotation.ExceptionHandler;
|
||||
import org.springframework.web.bind.annotation.RestControllerAdvice;
|
||||
import org.springframework.web.method.annotation.MethodArgumentTypeMismatchException;
|
||||
import org.springframework.web.servlet.resource.NoResourceFoundException;
|
||||
|
||||
import java.util.LinkedHashMap;
|
||||
import java.util.Map;
|
||||
|
||||
/**
|
||||
* Maps validation, malformed JSON, and application exceptions to the documented
|
||||
* error response shape for the Secure Notes Vault API.
|
||||
*/
|
||||
@RestControllerAdvice
|
||||
public class GlobalExceptionHandler {
|
||||
|
||||
/**
|
||||
* @param ex conflict exception raised by the application layer
|
||||
* @return 409 response containing a client-safe error message
|
||||
*/
|
||||
@ExceptionHandler(ConflictException.class)
|
||||
public ResponseEntity<ErrorResponse> handleConflict(ConflictException ex) {
|
||||
return ResponseEntity
|
||||
.status(HttpStatus.CONFLICT)
|
||||
.body(new ErrorResponse(ex.getMessage()));
|
||||
}
|
||||
|
||||
/**
|
||||
* @param ex forbidden exception raised by the application layer
|
||||
* @return 403 response containing a client-safe error message
|
||||
*/
|
||||
@ExceptionHandler(ForbiddenException.class)
|
||||
public ResponseEntity<ErrorResponse> handleForbidden(ForbiddenException ex) {
|
||||
return ResponseEntity
|
||||
.status(HttpStatus.FORBIDDEN)
|
||||
.body(new ErrorResponse(ex.getMessage()));
|
||||
}
|
||||
|
||||
/**
|
||||
* @param ex not-found exception raised by the application layer
|
||||
* @return 404 response containing a client-safe error message
|
||||
*/
|
||||
@ExceptionHandler(NotFoundException.class)
|
||||
public ResponseEntity<ErrorResponse> handleNotFound(NotFoundException ex) {
|
||||
return ResponseEntity
|
||||
.status(HttpStatus.NOT_FOUND)
|
||||
.body(new ErrorResponse(ex.getMessage()));
|
||||
}
|
||||
|
||||
/**
|
||||
* @param ex authentication exception raised by Spring Security
|
||||
* @return 401 response containing a generic credential failure message
|
||||
*/
|
||||
@ExceptionHandler(AuthenticationException.class)
|
||||
public ResponseEntity<ErrorResponse> handleAuthentication(AuthenticationException ex) {
|
||||
return ResponseEntity
|
||||
.status(HttpStatus.UNAUTHORIZED)
|
||||
.body(new ErrorResponse("Invalid username or password"));
|
||||
}
|
||||
|
||||
/**
|
||||
* @param ex validation exception raised while binding a request body
|
||||
* @return 422 response containing validation summary and field error messages
|
||||
*/
|
||||
@ExceptionHandler(MethodArgumentNotValidException.class)
|
||||
public ResponseEntity<ValidationErrorResponse> handleValidation(MethodArgumentNotValidException ex) {
|
||||
Map<String, String> fields = new LinkedHashMap<>();
|
||||
ex.getBindingResult().getFieldErrors().forEach(error ->
|
||||
fields.putIfAbsent(error.getField(), error.getDefaultMessage())
|
||||
);
|
||||
|
||||
return ResponseEntity
|
||||
.status(HttpStatus.UNPROCESSABLE_ENTITY)
|
||||
.body(new ValidationErrorResponse("Validation failed", fields));
|
||||
}
|
||||
|
||||
/**
|
||||
* @param ex JSON parsing or request-body conversion failure
|
||||
* @return 400 response containing a client-safe error message
|
||||
*/
|
||||
@ExceptionHandler(HttpMessageNotReadableException.class)
|
||||
public ResponseEntity<ErrorResponse> handleMalformedJson(HttpMessageNotReadableException ex) {
|
||||
return ResponseEntity
|
||||
.status(HttpStatus.BAD_REQUEST)
|
||||
.body(new ErrorResponse("Malformed request body"));
|
||||
}
|
||||
|
||||
/**
|
||||
* @param ex type conversion failure raised while binding a request parameter or path variable
|
||||
* @return 400 response containing a client-safe error message
|
||||
*/
|
||||
@ExceptionHandler(MethodArgumentTypeMismatchException.class)
|
||||
public ResponseEntity<ErrorResponse> handleTypeMismatch(MethodArgumentTypeMismatchException ex) {
|
||||
return ResponseEntity
|
||||
.status(HttpStatus.BAD_REQUEST)
|
||||
.body(new ErrorResponse("Invalid request parameter"));
|
||||
}
|
||||
|
||||
/**
|
||||
* @param ex exception raised when Spring MVC cannot resolve a resource for the route
|
||||
* @return 404 response containing a client-safe error message
|
||||
*/
|
||||
@ExceptionHandler(NoResourceFoundException.class)
|
||||
public ResponseEntity<ErrorResponse> handleNoResourceFound(NoResourceFoundException ex) {
|
||||
return ResponseEntity
|
||||
.status(HttpStatus.NOT_FOUND)
|
||||
.body(new ErrorResponse("Not found"));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,17 @@
|
||||
/**
|
||||
* NotFoundException.java
|
||||
*/
|
||||
package com.seanstarkey.notesvault.exception;
|
||||
|
||||
/**
|
||||
* Signals an HTTP 404 Not Found condition for missing or inaccessible resources.
|
||||
*/
|
||||
public class NotFoundException extends RuntimeException {
|
||||
|
||||
/**
|
||||
* @param message human-readable description of the missing resource
|
||||
*/
|
||||
public NotFoundException(String message) {
|
||||
super(message);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,25 @@
|
||||
/**
|
||||
* UserRepository.java
|
||||
*/
|
||||
package com.seanstarkey.notesvault.repository;
|
||||
|
||||
import com.seanstarkey.notesvault.entity.User;
|
||||
import org.springframework.data.jpa.repository.JpaRepository;
|
||||
|
||||
import java.util.Optional;
|
||||
import java.util.UUID;
|
||||
|
||||
public interface UserRepository extends JpaRepository<User, UUID> {
|
||||
|
||||
/**
|
||||
* @param username the username to search for
|
||||
* @return an Optional containing the matching user, or empty when no user exists
|
||||
*/
|
||||
Optional<User> findByUsername(String username);
|
||||
|
||||
/**
|
||||
* @param username the username to test for uniqueness
|
||||
* @return true when the username is already registered; false otherwise
|
||||
*/
|
||||
boolean existsByUsername(String username);
|
||||
}
|
||||
51
src/main/resources/db/migration/V1__init.sql
Normal file
51
src/main/resources/db/migration/V1__init.sql
Normal file
@@ -0,0 +1,51 @@
|
||||
/*
|
||||
* V1__init.sql
|
||||
*
|
||||
* Creates the initial Secure Notes Vault schema managed by Flyway. The tables
|
||||
* mirror the User, Note, and NoteShare JPA entities and keep the SQL portable
|
||||
* between H2 in PostgreSQL compatibility mode and PostgreSQL.
|
||||
*/
|
||||
|
||||
CREATE TABLE users (
|
||||
id UUID PRIMARY KEY,
|
||||
username VARCHAR(100) NOT NULL,
|
||||
password_hash VARCHAR(255) NOT NULL,
|
||||
created_at TIMESTAMP WITH TIME ZONE NOT NULL,
|
||||
CONSTRAINT uq_users_username UNIQUE (username)
|
||||
);
|
||||
|
||||
CREATE TABLE notes (
|
||||
id UUID PRIMARY KEY,
|
||||
owner_id UUID NOT NULL,
|
||||
title VARCHAR(255) NOT NULL,
|
||||
content TEXT NOT NULL,
|
||||
created_at TIMESTAMP WITH TIME ZONE NOT NULL,
|
||||
updated_at TIMESTAMP WITH TIME ZONE NOT NULL,
|
||||
CONSTRAINT fk_notes_owner
|
||||
FOREIGN KEY (owner_id)
|
||||
REFERENCES users (id)
|
||||
);
|
||||
|
||||
CREATE INDEX idx_notes_owner_id ON notes (owner_id);
|
||||
|
||||
CREATE TABLE note_shares (
|
||||
id UUID PRIMARY KEY,
|
||||
note_id UUID NOT NULL,
|
||||
shared_with_user_id UUID NOT NULL,
|
||||
created_at TIMESTAMP WITH TIME ZONE NOT NULL,
|
||||
CONSTRAINT fk_note_shares_note
|
||||
FOREIGN KEY (note_id)
|
||||
REFERENCES notes (id)
|
||||
ON DELETE CASCADE,
|
||||
CONSTRAINT fk_note_shares_shared_with
|
||||
FOREIGN KEY (shared_with_user_id)
|
||||
REFERENCES users (id),
|
||||
CONSTRAINT uq_note_shares_note_user
|
||||
UNIQUE (note_id, shared_with_user_id)
|
||||
);
|
||||
|
||||
CREATE INDEX idx_note_shares_shared_with_user_id
|
||||
ON note_shares (shared_with_user_id);
|
||||
|
||||
CREATE INDEX idx_note_shares_note_id
|
||||
ON note_shares (note_id);
|
||||
@@ -0,0 +1,82 @@
|
||||
/**
|
||||
* AuthControllerTest.java
|
||||
*/
|
||||
package com.seanstarkey.notesvault.auth;
|
||||
|
||||
import com.seanstarkey.notesvault.repository.UserRepository;
|
||||
import org.junit.jupiter.api.Test;
|
||||
import org.springframework.beans.factory.annotation.Autowired;
|
||||
import org.springframework.boot.test.autoconfigure.web.servlet.AutoConfigureMockMvc;
|
||||
import org.springframework.boot.test.context.SpringBootTest;
|
||||
import org.springframework.http.MediaType;
|
||||
import org.springframework.security.crypto.password.PasswordEncoder;
|
||||
import org.springframework.test.web.servlet.MockMvc;
|
||||
import org.springframework.transaction.annotation.Transactional;
|
||||
|
||||
import static org.assertj.core.api.Assertions.assertThat;
|
||||
import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.post;
|
||||
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.jsonPath;
|
||||
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status;
|
||||
|
||||
@SpringBootTest
|
||||
@AutoConfigureMockMvc
|
||||
@Transactional
|
||||
class AuthControllerTest {
|
||||
|
||||
@Autowired
|
||||
private MockMvc mockMvc;
|
||||
|
||||
@Autowired
|
||||
private UserRepository userRepository;
|
||||
|
||||
@Autowired
|
||||
private PasswordEncoder passwordEncoder;
|
||||
|
||||
@Test
|
||||
void registerCreatesUserAndReturnsPublicProfile() throws Exception {
|
||||
mockMvc.perform(post("/auth/register")
|
||||
.contentType(MediaType.APPLICATION_JSON)
|
||||
.content("""
|
||||
{
|
||||
"username": "starkey",
|
||||
"password": "secret123"
|
||||
}
|
||||
"""))
|
||||
.andExpect(status().isCreated())
|
||||
.andExpect(jsonPath("$.id").isNotEmpty())
|
||||
.andExpect(jsonPath("$.username").value("starkey"))
|
||||
.andExpect(jsonPath("$.createdAt").isNotEmpty())
|
||||
.andExpect(jsonPath("$.password").doesNotExist())
|
||||
.andExpect(jsonPath("$.passwordHash").doesNotExist());
|
||||
|
||||
var savedUser = userRepository.findByUsername("starkey").orElseThrow();
|
||||
assertThat(savedUser.getPasswordHash()).isNotEqualTo("secret123");
|
||||
assertThat(passwordEncoder.matches("secret123", savedUser.getPasswordHash())).isTrue();
|
||||
}
|
||||
|
||||
/*
|
||||
* Test: duplicate usernames are rejected.
|
||||
* Verifies that the unique account invariant is enforced at the API boundary
|
||||
* with HTTP 409 instead of silently overwriting or exposing database details.
|
||||
*/
|
||||
@Test
|
||||
void registerRejectsDuplicateUsername() throws Exception {
|
||||
register("starkey", "secret123").andExpect(status().isCreated());
|
||||
|
||||
register("starkey", "another123")
|
||||
.andExpect(status().isConflict())
|
||||
.andExpect(jsonPath("$.error").value("Username is already registered"));
|
||||
}
|
||||
|
||||
|
||||
private org.springframework.test.web.servlet.ResultActions register(String username, String password) throws Exception {
|
||||
return mockMvc.perform(post("/auth/register")
|
||||
.contentType(MediaType.APPLICATION_JSON)
|
||||
.content("""
|
||||
{
|
||||
"username": "%s",
|
||||
"password": "%s"
|
||||
}
|
||||
""".formatted(username, password)));
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user