Регистрация и логин (User entity + JPA)
Цель урока
Сделать полноценный CRUD для задач с правильными HTTP-статусами (201/204/404), обработкой ошибок, и подготовить Task к подключению JPA.
Теория · для собеса
1 Схема базы данных для User ▸
CREATE TABLE users (
id BIGSERIAL PRIMARY KEY,
username VARCHAR(50) UNIQUE NOT NULL,
email VARCHAR(255) UNIQUE NOT NULL,
password_hash VARCHAR(100) NOT NULL, -- BCrypt, никогда не plain!
role VARCHAR(20) NOT NULL DEFAULT 'USER',
enabled BOOLEAN NOT NULL DEFAULT TRUE,
created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP
);
Почему password_hash, а не password?
- В коде ясно, что это уже хеш, не plain
- Если кто-то увидит поле, не подумает что plain
2
Кастомный UserDetailsService
▸
Spring Security требует бин UserDetailsService, у которого есть метод loadUserByUsername(String). Возвращает UserDetails.
В нашем случае — загружаем User из БД, оборачиваем в Spring'овский User (через User.withUsername(...).password(...).roles(...).build()).
3
BCryptPasswordEncoder.matches(rawPassword, hash)
▸
- На вход:
"password123"(raw) +"$2a$12$..."(hash из БД) - Возвращает
true, если raw совпадает с тем, что был захэширован
⚠️ Никогда не пиши encoder.encode(raw).equals(storedHash) — оно не сработает корректно (разные соли).
---
Практика: улучшаем CRUD
-
1Flyway-миграция
V2__add_users.sqlCREATE TABLE users ( id BIGSERIAL PRIMARY KEY, username VARCHAR(50) UNIQUE NOT NULL, email VARCHAR(255) UNIQUE NOT NULL, password_hash VARCHAR(100) NOT NULL, role VARCHAR(20) NOT NULL DEFAULT 'USER', enabled BOOLEAN NOT NULL DEFAULT TRUE, created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP ); CREATE INDEX idx_users_username ON users(username); CREATE INDEX idx_users_email ON users(email); -
2
model/User.javapackage com.taskflow.model; import jakarta.persistence.*; import java.time.Instant; @Entity @Table(name = "users") public class User { @Id @GeneratedValue(strategy = GenerationType.IDENTITY) private Long id; @Column(nullable = false, unique = true, length = 50) private String username; @Column(nullable = false, unique = true) private String email; @Column(name = "password_hash", nullable = false, length = 100) private String passwordHash; @Column(nullable = false, length = 20) @Enumerated(EnumType.STRING) private Role role = Role.USER; @Column(nullable = false) private boolean enabled = true; @Column(name = "created_at", nullable = false, updatable = false) private Instant createdAt; public enum Role { USER, ADMIN } @PrePersist void onCreate() { this.createdAt = Instant.now(); } public User() {} public User(String username, String email, String passwordHash, Role role) { this.username = username; this.email = email; this.passwordHash = passwordHash; this.role = role; } // Getters / Setters public Long getId() { return id; } public void setId(Long id) { this.id = id; } public String getUsername() { return username; } public void setUsername(String username) { this.username = username; } public String getEmail() { return email; } public void setEmail(String email) { this.email = email; } public String getPasswordHash() { return passwordHash; } public void setPasswordHash(String passwordHash) { this.passwordHash = passwordHash; } public Role getRole() { return role; } public void setRole(Role role) { this.role = role; } public boolean isEnabled() { return enabled; } public void setEnabled(boolean enabled) { this.enabled = enabled; } public Instant getCreatedAt() { return createdAt; } } -
3
repository/UserRepository.javapackage com.taskflow.repository; import com.taskflow.model.User; import org.springframework.data.jpa.repository.JpaRepository; import org.springframework.stereotype.Repository; import java.util.Optional; @Repository public interface UserRepository extends JpaRepository{ Optional findByUsername(String username); Optional findByEmail(String email); boolean existsByUsername(String username); boolean existsByEmail(String email); } -
4
security/CustomUserDetailsService.javapackage com.taskflow.security; import com.taskflow.repository.UserRepository; 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 UserRepository userRepository; public CustomUserDetailsService(UserRepository userRepository) { this.userRepository = userRepository; } @Override public UserDetails loadUserByUsername(String username) throws UsernameNotFoundException { com.taskflow.model.User user = userRepository.findByUsername(username) .orElseThrow(() -> new UsernameNotFoundException("User not found: " + username)); return User.withUsername(user.getUsername()) .password(user.getPasswordHash()) .authorities(List.of(new SimpleGrantedAuthority("ROLE_" + user.getRole().name()))) .disabled(!user.isEnabled()) .build(); } } -
5DTO для auth
package com.taskflow.dto; import jakarta.validation.constraints.Email; import jakarta.validation.constraints.NotBlank; import jakarta.validation.constraints.Size; public record RegisterRequest( @NotBlank @Size(min = 3, max = 50) String username, @NotBlank @Email String email, @NotBlank @Size(min = 8, max = 100) String password ) {}package com.taskflow.dto; import jakarta.validation.constraints.NotBlank; public record LoginRequest( @NotBlank String username, @NotBlank String password ) {}package com.taskflow.dto; import com.taskflow.model.User; public record UserResponse( Long id, String username, String email, String role ) { public static UserResponse from(User user) { return new UserResponse(user.getId(), user.getUsername(), user.getEmail(), user.getRole().name()); } } -
6
service/AuthService.javapackage com.taskflow.service; import com.taskflow.dto.LoginRequest; import com.taskflow.dto.RegisterRequest; import com.taskflow.dto.UserResponse; import com.taskflow.exception.BusinessException; import com.taskflow.model.User; import com.taskflow.repository.UserRepository; import org.springframework.security.crypto.password.PasswordEncoder; import org.springframework.stereotype.Service; import org.springframework.transaction.annotation.Transactional; @Service public class AuthService { private final UserRepository userRepository; private final PasswordEncoder passwordEncoder; public AuthService(UserRepository userRepository, PasswordEncoder passwordEncoder) { this.userRepository = userRepository; this.passwordEncoder = passwordEncoder; } @Transactional public UserResponse register(RegisterRequest req) { if (userRepository.existsByUsername(req.username())) { throw new BusinessException("Username already taken: " + req.username()); } if (userRepository.existsByEmail(req.email())) { throw new BusinessException("Email already taken: " + req.email()); } User user = new User( req.username(), req.email(), passwordEncoder.encode(req.password()), User.Role.USER ); return UserResponse.from(userRepository.save(user)); } // Логин — в уроке 12 (AuthenticationManager) } -
7
controller/AuthController.javapackage com.taskflow.controller; import com.taskflow.dto.LoginRequest; import com.taskflow.dto.RegisterRequest; import com.taskflow.dto.UserResponse; import com.taskflow.service.AuthService; import jakarta.validation.Valid; import org.springframework.http.HttpStatus; import org.springframework.http.ResponseEntity; import org.springframework.web.bind.annotation.*; @RestController @RequestMapping("/api/auth") public class AuthController { private final AuthService authService; public AuthController(AuthService authService) { this.authService = authService; } @PostMapping("/register") @ResponseStatus(HttpStatus.CREATED) public UserResponse register(@Valid @RequestBody RegisterRequest req) { return authService.register(req); } @PostMapping("/login") public ResponseEntity> login(@Valid @RequestBody LoginRequest req) { // В уроке 12 заменим на JWT return ResponseEntity.ok("Login successful for " + req.username() + " (JWT in next lesson)"); } } -
8Тест
# Регистрация $ curl -i -X POST http://localhost:8080/api/auth/register \ -H "Content-Type: application/json" \ -d '{"username":"alice","email":"alice@example.com","password":"password123"}' HTTP/1.1 201 {"id":1,"username":"alice","email":"alice@example.com","role":"USER"} # Дубликат username → 409 $ curl -i -X POST http://localhost:8080/api/auth/register \ -H "Content-Type: application/json" \ -d '{"username":"alice","email":"alice2@example.com","password":"password123"}' HTTP/1.1 409 {"status":409,"error":"Conflict","message":"Username already taken: alice",...} # Слабый пароль → 400 $ curl -i -X POST http://localhost:8080/api/auth/register \ -H "Content-Type: application/json" \ -d '{"username":"bob","email":"bob@example.com","password":"123"}' HTTP/1.1 400 {"status":400,"error":"Bad Request","fieldErrors":[{"field":"password","message":"size must be between 8 and 100"}]}---
Зачем это на собесе
После урока ты должен уметь ответить на:
password_hash VARCHAR(100) — BCrypt всегда даёт 60 символов, 100 с запасом на будущее.UNIQUE constraints — нельзя зарегистрироваться с занятым username/email. Проверяем в коде (быстрый fail с 409).@Transactional — если БД бросит исключение, откатим всё (например, race condition при дубликате).RegisterRequest целиком — там пароль в plain!5 вопросов на углубление
Раскрой вопрос и нажми «🤔 Хочу разобрать подробнее» — он попадёт в страницу ответов.
Разбор внутри: BCrypt, Argon2, scrypt и миграция алгоритмов.
Разбор внутри: race condition между проверкой и сохранением пользователя.
Разбор внутри: удобная ошибка для пользователя и гарантия на уровне БД.
Разбор внутри: passwordHash, чувствительные поля и DTO для ответа.
Разбор внутри: стандартный выбор для обычного проекта и усиленные варианты.
Готов идти дальше?
Выбери вопросы, которые тебе интересны, и изучи их. Потом — к следующему уроку.
🚀 Перейти к Уроку 12Сначала пройди все секции и выбери хотя бы 1 вопрос
⬅️ Назад к Уроку 10