Implement auth domain with outside-in TDD (22 tests)

Controller (7 tests): signup, login, logout, GET/PATCH me.
Standalone MockMvc setup (Boot 4 removed @WebMvcTest).

Service (11 tests): signup with conflict check, login with
password/active validation, getCurrentUser with household info,
updateProfile with password change flow.

Repository (4 tests): save/find, case-insensitive email via
IgnoreCase queries (citext + Hibernate needs explicit IgnoreCase),
existsByEmail.

Also includes:
- SecurityConfig: session auth, CSRF, role-based authorization
- CustomUserDetailsService: loads UserAccount for Spring Security
- UserAccount, Household, HouseholdMember JPA entities
- spring-boot-flyway dependency (Boot 4 requires explicit module)
- ddl-auto=none (Flyway owns schema, validate fails on citext)

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
2026-04-01 21:24:26 +02:00
parent 866603711d
commit 3253dcfec2
23 changed files with 873 additions and 15 deletions

View File

@@ -0,0 +1,65 @@
package com.recipeapp.auth;
import com.recipeapp.auth.dto.*;
import com.recipeapp.common.ApiResponse;
import jakarta.servlet.http.HttpServletRequest;
import jakarta.servlet.http.HttpSession;
import jakarta.validation.Valid;
import org.springframework.http.HttpStatus;
import org.springframework.http.ResponseEntity;
import org.springframework.web.bind.annotation.*;
import java.security.Principal;
@RestController
@RequestMapping("/v1/auth")
public class AuthController {
private final AuthService authService;
public AuthController(AuthService authService) {
this.authService = authService;
}
@PostMapping("/signup")
public ResponseEntity<ApiResponse<UserResponse>> signup(
@Valid @RequestBody SignupRequest request,
HttpServletRequest httpRequest) {
UserResponse user = authService.signup(request);
httpRequest.getSession(true);
return ResponseEntity.status(HttpStatus.CREATED).body(ApiResponse.success(user));
}
@PostMapping("/login")
public ResponseEntity<ApiResponse<UserResponse>> login(
@Valid @RequestBody LoginRequest request,
HttpServletRequest httpRequest) {
UserResponse user = authService.login(request);
HttpSession session = httpRequest.getSession(true);
session.setAttribute("user_email", user.email());
return ResponseEntity.ok(ApiResponse.success(user));
}
@PostMapping("/logout")
public ResponseEntity<Void> logout(HttpServletRequest httpRequest) {
HttpSession session = httpRequest.getSession(false);
if (session != null) {
session.invalidate();
}
return ResponseEntity.noContent().build();
}
@GetMapping("/me")
public ResponseEntity<ApiResponse<UserResponse>> me(Principal principal) {
UserResponse user = authService.getCurrentUser(principal.getName());
return ResponseEntity.ok(ApiResponse.success(user));
}
@PatchMapping("/me")
public ResponseEntity<ApiResponse<UserResponse>> updateProfile(
Principal principal,
@Valid @RequestBody UpdateProfileRequest request) {
UserResponse user = authService.updateProfile(principal.getName(), request);
return ResponseEntity.ok(ApiResponse.success(user));
}
}

View File

@@ -0,0 +1,10 @@
package com.recipeapp.auth;
import com.recipeapp.auth.dto.*;
public interface AuthService {
UserResponse signup(SignupRequest request);
UserResponse login(LoginRequest request);
UserResponse getCurrentUser(String email);
UserResponse updateProfile(String email, UpdateProfileRequest request);
}

View File

@@ -0,0 +1,99 @@
package com.recipeapp.auth;
import com.recipeapp.auth.dto.*;
import com.recipeapp.auth.entity.UserAccount;
import com.recipeapp.common.ConflictException;
import com.recipeapp.common.ResourceNotFoundException;
import com.recipeapp.common.ValidationException;
import com.recipeapp.household.HouseholdMemberRepository;
import com.recipeapp.household.entity.HouseholdMember;
import org.springframework.security.crypto.password.PasswordEncoder;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
@Service
public class AuthServiceImpl implements AuthService {
private final UserAccountRepository userAccountRepository;
private final HouseholdMemberRepository householdMemberRepository;
private final PasswordEncoder passwordEncoder;
public AuthServiceImpl(UserAccountRepository userAccountRepository,
HouseholdMemberRepository householdMemberRepository,
PasswordEncoder passwordEncoder) {
this.userAccountRepository = userAccountRepository;
this.householdMemberRepository = householdMemberRepository;
this.passwordEncoder = passwordEncoder;
}
@Override
@Transactional
public UserResponse signup(SignupRequest request) {
if (userAccountRepository.existsByEmailIgnoreCase(request.email())) {
throw new ConflictException("Email already registered");
}
var user = new UserAccount(
request.email(),
request.displayName(),
passwordEncoder.encode(request.password())
);
user = userAccountRepository.save(user);
return UserResponse.basic(user.getId(), user.getEmail(), user.getDisplayName());
}
@Override
@Transactional(readOnly = true)
public UserResponse login(LoginRequest request) {
UserAccount user = userAccountRepository.findByEmailIgnoreCase(request.email())
.orElseThrow(() -> new ResourceNotFoundException("Invalid email or password"));
if (!user.isActive()) {
throw new ValidationException("Account is deactivated");
}
if (!passwordEncoder.matches(request.password(), user.getPasswordHash())) {
throw new ValidationException("Invalid email or password");
}
return toUserResponse(user);
}
@Override
@Transactional(readOnly = true)
public UserResponse getCurrentUser(String email) {
UserAccount user = userAccountRepository.findByEmailIgnoreCase(email)
.orElseThrow(() -> new ResourceNotFoundException("User not found"));
return toUserResponse(user);
}
@Override
@Transactional
public UserResponse updateProfile(String email, UpdateProfileRequest request) {
UserAccount user = userAccountRepository.findByEmailIgnoreCase(email)
.orElseThrow(() -> new ResourceNotFoundException("User not found"));
if (request.displayName() != null) {
user.setDisplayName(request.displayName());
}
if (request.newPassword() != null) {
if (request.currentPassword() == null) {
throw new ValidationException("Current password is required to set a new password");
}
if (!passwordEncoder.matches(request.currentPassword(), user.getPasswordHash())) {
throw new ValidationException("Current password is incorrect");
}
user.setPasswordHash(passwordEncoder.encode(request.newPassword()));
}
user = userAccountRepository.save(user);
return UserResponse.basic(user.getId(), user.getEmail(), user.getDisplayName());
}
private UserResponse toUserResponse(UserAccount user) {
return householdMemberRepository.findByUserEmailIgnoreCase(user.getEmail())
.map(member -> UserResponse.withHousehold(
user.getId(), user.getEmail(), user.getDisplayName(),
member.getHousehold().getId(), member.getHousehold().getName(),
member.getRole(), user.getSystemRole()))
.orElse(UserResponse.withHousehold(
user.getId(), user.getEmail(), user.getDisplayName(),
null, null, null, user.getSystemRole()));
}
}

View File

@@ -0,0 +1,35 @@
package com.recipeapp.auth;
import com.recipeapp.auth.entity.UserAccount;
import org.springframework.security.core.authority.SimpleGrantedAuthority;
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;
import java.util.List;
@Service
public class CustomUserDetailsService implements UserDetailsService {
private final UserAccountRepository userAccountRepository;
public CustomUserDetailsService(UserAccountRepository userAccountRepository) {
this.userAccountRepository = userAccountRepository;
}
@Override
public UserDetails loadUserByUsername(String email) throws UsernameNotFoundException {
UserAccount account = userAccountRepository.findByEmailIgnoreCase(email)
.orElseThrow(() -> new UsernameNotFoundException("User not found: " + email));
if (!account.isActive()) {
throw new UsernameNotFoundException("Account is deactivated: " + email);
}
var authorities = List.of(new SimpleGrantedAuthority("ROLE_" + account.getSystemRole().toUpperCase()));
return new User(account.getEmail(), account.getPasswordHash(), authorities);
}
}

View File

@@ -0,0 +1,43 @@
package com.recipeapp.auth;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.http.HttpMethod;
import org.springframework.http.HttpStatus;
import org.springframework.security.config.annotation.web.builders.HttpSecurity;
import org.springframework.security.config.annotation.web.configuration.EnableWebSecurity;
import org.springframework.security.crypto.bcrypt.BCryptPasswordEncoder;
import org.springframework.security.crypto.password.PasswordEncoder;
import org.springframework.security.web.SecurityFilterChain;
import org.springframework.security.web.authentication.HttpStatusEntryPoint;
import org.springframework.security.web.csrf.CookieCsrfTokenRepository;
import org.springframework.security.web.csrf.CsrfTokenRequestAttributeHandler;
@Configuration
@EnableWebSecurity
public class SecurityConfig {
@Bean
public SecurityFilterChain securityFilterChain(HttpSecurity http) throws Exception {
http
.csrf(csrf -> csrf
.csrfTokenRepository(CookieCsrfTokenRepository.withHttpOnlyFalse())
.csrfTokenRequestHandler(new CsrfTokenRequestAttributeHandler()))
.authorizeHttpRequests(auth -> auth
.requestMatchers("/v1/auth/signup", "/v1/auth/login").permitAll()
.requestMatchers("/swagger-ui/**", "/v3/api-docs/**").permitAll()
.requestMatchers("/v1/admin/**").hasAuthority("ROLE_ADMIN")
.anyRequest().authenticated())
.exceptionHandling(ex -> ex
.authenticationEntryPoint(new HttpStatusEntryPoint(HttpStatus.UNAUTHORIZED)))
.sessionManagement(session -> session
.maximumSessions(1));
return http.build();
}
@Bean
public PasswordEncoder passwordEncoder() {
return new BCryptPasswordEncoder();
}
}

View File

@@ -0,0 +1,12 @@
package com.recipeapp.auth;
import com.recipeapp.auth.entity.UserAccount;
import org.springframework.data.jpa.repository.JpaRepository;
import java.util.Optional;
import java.util.UUID;
public interface UserAccountRepository extends JpaRepository<UserAccount, UUID> {
Optional<UserAccount> findByEmailIgnoreCase(String email);
boolean existsByEmailIgnoreCase(String email);
}

View File

@@ -0,0 +1,9 @@
package com.recipeapp.auth.dto;
import jakarta.validation.constraints.Email;
import jakarta.validation.constraints.NotBlank;
public record LoginRequest(
@NotBlank @Email String email,
@NotBlank String password
) {}

View File

@@ -0,0 +1,11 @@
package com.recipeapp.auth.dto;
import jakarta.validation.constraints.Email;
import jakarta.validation.constraints.NotBlank;
import jakarta.validation.constraints.Size;
public record SignupRequest(
@NotBlank @Email String email,
@NotBlank @Size(min = 8) String password,
@NotBlank @Size(max = 100) String displayName
) {}

View File

@@ -0,0 +1,9 @@
package com.recipeapp.auth.dto;
import jakarta.validation.constraints.Size;
public record UpdateProfileRequest(
@Size(max = 100) String displayName,
String currentPassword,
@Size(min = 8) String newPassword
) {}

View File

@@ -0,0 +1,26 @@
package com.recipeapp.auth.dto;
import com.fasterxml.jackson.annotation.JsonInclude;
import java.util.UUID;
@JsonInclude(JsonInclude.Include.NON_NULL)
public record UserResponse(
UUID id,
String email,
String displayName,
UUID householdId,
String householdName,
String householdRole,
String systemRole
) {
public static UserResponse basic(UUID id, String email, String displayName) {
return new UserResponse(id, email, displayName, null, null, null, null);
}
public static UserResponse withHousehold(UUID id, String email, String displayName,
UUID householdId, String householdName,
String householdRole, String systemRole) {
return new UserResponse(id, email, displayName, householdId, householdName,
householdRole, systemRole);
}
}

View File

@@ -0,0 +1,68 @@
package com.recipeapp.auth.entity;
import jakarta.persistence.*;
import java.time.Instant;
import java.util.UUID;
@Entity
@Table(name = "user_account")
public class UserAccount {
@Id
@GeneratedValue(strategy = GenerationType.UUID)
private UUID id;
@Column(nullable = false, unique = true, columnDefinition = "citext")
private String email;
@Column(name = "display_name", nullable = false, length = 100)
private String displayName;
@Column(name = "password_hash", nullable = false)
private String passwordHash;
@Column(name = "system_role", nullable = false, length = 10)
private String systemRole = "user";
@Column(name = "is_active", nullable = false)
private boolean isActive = true;
@Column(name = "created_at", nullable = false, updatable = false)
private Instant createdAt;
@Column(name = "updated_at", nullable = false)
private Instant updatedAt;
protected UserAccount() {}
public UserAccount(String email, String displayName, String passwordHash) {
this.email = email;
this.displayName = displayName;
this.passwordHash = passwordHash;
}
@PrePersist
void onCreate() {
createdAt = Instant.now();
updatedAt = Instant.now();
}
@PreUpdate
void onUpdate() {
updatedAt = Instant.now();
}
public UUID getId() { return id; }
public String getEmail() { return email; }
public void setEmail(String email) { this.email = email; }
public String getDisplayName() { return displayName; }
public void setDisplayName(String displayName) { this.displayName = displayName; }
public String getPasswordHash() { return passwordHash; }
public void setPasswordHash(String passwordHash) { this.passwordHash = passwordHash; }
public String getSystemRole() { return systemRole; }
public void setSystemRole(String systemRole) { this.systemRole = systemRole; }
public boolean isActive() { return isActive; }
public void setActive(boolean active) { isActive = active; }
public Instant getCreatedAt() { return createdAt; }
public Instant getUpdatedAt() { return updatedAt; }
}