УРОК 11 / 30 0%

Регистрация и логин (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

  1. 1
    Flyway-миграция V2__add_users.sql
    
    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,
        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. 2
    model/User.java
    
    package 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. 3
    repository/UserRepository.java
    
    package 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. 4
    security/CustomUserDetailsService.java
    
    package 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();
        }
    }
    
  5. 5
    DTO для 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. 6
    service/AuthService.java
    
    package 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. 7
    controller/AuthController.java
    
    package 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. 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 вопросов на углубление

Раскрой вопрос и нажми «🤔 Хочу разобрать подробнее» — он попадёт в страницу ответов.

1
Почему passwordHash лучше хранить с запасом по длине?

Разбор внутри: BCrypt, Argon2, scrypt и миграция алгоритмов.

2
Почему existsByUsername без UNIQUE constraint недостаточен?

Разбор внутри: race condition между проверкой и сохранением пользователя.

3
existsBy + UNIQUE — зачем нужны оба барьера?

Разбор внутри: удобная ошибка для пользователя и гарантия на уровне БД.

4
Почему нельзя возвращать User entity из auth API?

Разбор внутри: passwordHash, чувствительные поля и DTO для ответа.

5
BCrypt vs Argon2 — что выбрать для паролей?

Разбор внутри: стандартный выбор для обычного проекта и усиленные варианты.

Готов идти дальше?

Выбери вопросы, которые тебе интересны, и изучи их. Потом — к следующему уроку.

🚀 Перейти к Уроку 12

Сначала пройди все секции и выбери хотя бы 1 вопрос

⬅️ Назад к Уроку 10
🎉
Новый тир
Новый тир достигнут!