하위 태스크 1

Spring Security 의존성 추가

build.gradle에 Security 의존성 확인

build.gradle에서 Spring Security 의존성이 추가된 것을 확인한다.

dependencies {
	// ...
	implementation 'org.springframework.boot:spring-boot-starter-security'
	implementation 'org.thymeleaf.extras:thymeleaf-extras-springsecurity6'
	testImplementation 'org.springframework.security:spring-security-test'
}

하위 태스크 2 ~ 3

SecurityConfig 클래스 생성

@EnableWebSecurity와 SecurityFilterChain 설정

기본 보안 설정 구성

인증이 필요한 경로와 허용 경로 설정

config/SecurityConfig.javaSecurityConfig 클래스를 작성한다. //public/** 패턴과 일치하는 위치에 대한 접근은 허용하고, 그 외의 위치는 모두 인증이 선행되어야 한다.

@Configuration
@EnableWebSecurity
public class SecurityConfig {
    @Bean
    public SecurityFilterChain filterChain(HttpSecurity http) throws Exception {
        http
                .authorizeHttpRequests(auth -> auth
                        .requestMatchers("/", "/public/**").permitAll()
                        .anyRequest().authenticated()
                )
                .formLogin(form -> form
                        .loginPage("/login")
                        .permitAll()
                );
        return http.build();
    }
}

필터 체인에 의해 /something과 같이 허가되지 않은 URL에 접근하면 로그인 페이지 /login으로 이동한다.

하위 태스크 4

User 엔티티 생성

사용자 정보를 저장할 엔티티 생성

사용자에 대응하는 User 엔티티 클래스를 생성한다.

model/User.java:

@Entity
@Data
@Builder
@NoArgsConstructor
@AllArgsConstructor
@Table(name = "users")
public class User {
    @Id
    @GeneratedValue(strategy = GenerationType.IDENTITY)
    private Long id;
 
    @Column(unique = true, nullable = false)
    private String username;
 
    @Column(nullable = false)
    private String password;
 
    private String email;
}

하위 태스크 5

UserRepository 생성

사용자 조회를 위한 Repository 생성

UserRepository 클래스를 생성한다.

repository/UserRepository.java:

public interface UserRepository extends JpaRepository<User, Long> {
    Optional<User> findByUsername(String username);
}

하위 태스크 6

CustomUserDetailsService 구현

UserDetailsService 인터페이스 구현

UserDetailsService 인터페이스를 구현한 CustomUserDetailsService 클래스를 생성한다.

service/CustomUserDetail.java:

@Service
public class CustomUserDetailsService implements UserDetailsService {
    @Autowired
    private UserRepository userRepository;
 
    @Override
    public UserDetails loadUserByUsername(String username) throws UsernameNotFoundException {
        User user = userRepository.findByUsername(username)
                .orElseThrow(() -> new UsernameNotFoundException("User not found"));
 
        return org.springframework.security.core.userdetails.User.builder()
                .username(user.getUsername())
                .password(user.getPassword())
                .roles("USER")
                .build();
    }
}

하위 태스크 7

PasswordEncoder Bean 등록

BCryptPasswordEncoder 등록

passwordEncoder 메서드를 Bean으로 등록한다.

public class SecurityConfig {
	// ...
	
+	@Bean
+   public PasswordEncoder passwordEncoder() {
+       return new BCryptPasswordEncoder();
+   }
}

Warning

model/User.javaUser 클래스 구조와 일치하도록 resources/schema.sqlresources/data.sql을 수정한다.

resources/schema.sql:

-- ...
create table users (
id bigint auto_increment primary key,
username varchar(50) not null unique,
password varchar(500) not null,
email varchar(255)
);
-- ...

로그인 후 http://localhost:8080/product/list에 접근하면 정상적으로 내용을 확인 할 수 있다.

하위 태스크 8

UserService 생성

회원가입 로직 구현

service/UserService.javaUserService 클래스를 구현한다.

@Service
public class UserService {
    @Autowired
    private UserRepository userRepository;
 
    @Autowired
    private PasswordEncoder passwordEncoder;
 
    public User registerUser(String username, String password, String email) {
        User user = new User();
        user.setUsername(username);
        user.setPassword(passwordEncoder.encode(password));
        user.setEmail(email);
        return userRepository.save(user);
    }
}

하위 태스크 9

회원가입 API 구현

POST /register 엔드포인트 구현

dto/UserRegistrationDto.javaUserRegistrationDto 클래스를 구현한다.

@Data
public class UserRegistrationDto {
    private String username;
    private String password;
    private String email;
}

controller/UserController.javaUserController 클래스를 구현한다.

@RestController
@RequiredArgsConstructor
public class UserController {
    private final UserService userService;
 
    @PostMapping("/register")
    public ResponseEntity<String> register(@RequestBody UserRegistrationDto dto) {
        userService.registerUser(dto.getUsername(), dto.getPassword(), dto.getEmail());
        return ResponseEntity.ok("User registered successfully");
    }
}

하위 태스크 10

로그인 페이지 생성

커스텀 로그인 페이지 HTML 생성

resources/templates/login-custom.html 파일을 작성한다.

<!doctype html>
<html lang="en" xmlns="http://www.w3.org/1999/xhtml" xmlns:th="https://www.thymeleaf.org">
<head>
    <style>
        body {
            width: 600px;
            padding: 20px;
            margin-left: auto;
            margin-right: auto;
            border-color: lightgray;
            border-style: solid;
            border-width: 1px;
        }
        input {
            border-color: gray;
            border-radius: 4px;
            border-style: solid;
            border-width: 1px;
            background-color: aliceblue;
        }
    </style>
</head>
<body>
<h1>로그인</h1>
<div th:if="${param.error}">아이디와 패스워드가 잘못 되었습니다</div>
<form th:action="@{/login}" method="post">
    <div>
        <input type="text" name="username" placeholder="아이디">
    </div>
    <div>
        <input type="password" name="password" placeholder="패스워드">
    </div>
    <div>
        자동 로그인 <input type="checkbox" name="remember-me" />
    </div>
    <button type="submit">로그인</button>
</form>
</body>
</html>

하위 태스크 11

로그인 성공/실패 핸들러 설정

SecurityConfig에서 핸들러 설정

config/SecurityConfig.java에서 로그인 성공 및 실패 핸들러를 설정한다.

public class SecurityConfig {
    @Bean
    public SecurityFilterChain filterChain(HttpSecurity http) throws Exception {
        http
                .authorizeHttpRequests(auth -> auth
                        .requestMatchers("/", "/public/**", "/login-custom").permitAll()
                        .anyRequest().authenticated()
                )
               .formLogin(form -> form
-                       .loginPage("/login")
+                       .loginPage("/login-custom")
+                       .successHandler((request, response, authentication) -> {
+                           System.out.println("로그인 성공: " + authentication.getName());
+                           response.sendRedirect("/");
+                       })
+                       .failureHandler((request, response, exception) -> {
+                           System.out.println("로그인 실패: " + exception.getMessage());
+                           response.sendRedirect("/login-custom?error");
+                       })
                        .permitAll()
                );
        return http.build();
    }
 
    // ...
}

controller/AuthenticationController.java/login-custom 경로로 매핑하기 위한 getLoginCustom 메서드를 추가한다.

public class AuthenticationController {
    // ...
 
+   @GetMapping("/login-custom")
+   public String getLoginCustom() {
+       return "login-custom";
+   }
 
    // ...
}

웹브라우저에서 http://localhost:8080/login-custom에 접속한 결과는 다음과 같다.

하위 태스크 12

세션 관리 설정

세션 타임아웃 및 관리 설정

application.properties를 수정해 세션 타임아웃 시간을 설정할 수 있다. 다음은 예시는 세션 타임아웃을 30분으로 설정한다.

# ...
 
server.servlet.session.timeout=30m

config/SecurityConfig.java에서 최대 허용 세션 수 등의 세션 관련 설정을 할 수 있다. 다음 예시는 최대 허용 세션을 1개로 제한하며, 중복 로그인 시 기존 세션을 만료시킨다.

public class SecurityConfig {
    @Bean
    public SecurityFilterChain filterChain(HttpSecurity http) throws Exception {
        http
                // ...
+               .sessionManagement(session -> session
+                       .maximumSessions(1)
+                       .maxSessionsPreventsLogin(false)
+               );
        return http.build();
    }
 
    // ...
}

참고 자료