브루트 포스 공격은 가능한 모든 비빌번호 조합을 자동화된 도구를 사용하여 시도함으로써 목표 시스템의 인증 정보를 알아내려는 공격 방식
공격자는 계정의 사용자 이름을 알고 있을 때, 해당 계정의 비밀번호를 맞출 때까지 수많은 비밀번호를 시도합니다. 일반적으로 사전 공격(Dictionary Attack)이나 무작위 조합을 사용하여 비밀번호를 시도합니다.
계정 잠금 정책(Account Lockout Policy): 일정 횟수 이상 로그인 실패 시 계정을 일시적으로 잠그는 방법
ex) 로그인 시도 횟수를 추적하는 필드를 User 엔티티에 추가하고, 실패 시 이를 증가시킵니다. 실패 횟수가 임계값을 초과하면 계정을 잠그고, 잠금 해제 시간을 설정합니다.
캡차(CAPTCHA) 사용 : 자동화된 스크립트의 접근을 방지하기 위해 사용자가 인간임을 증명하는 캡차를 도입합니다.
ex) Spring Security와 연동하여 로그인 폼에 Google reCAPTCHA 등을 통합합니다.
비밀번호 복잡성 강화 : 사용자가 복잡하고 예측하기 어려운 비밀번호를 사용하도록 요구합니다. 최소 길이, 대소문자, 숫자, 특수문자 조합 등을 설정합니다.
ex) Bean Validation을 사용하여 비밀번호 규칙을 검증합니다.
@Pattern(regexp = "^(?=.*[A-Z])(?=.*[a-z])(?=.*\\d)(?=.*[@#$%^&+=]).{8,}$",
message = "비밀번호는 최소 8자 이상, 대문자, 소문자, 숫자, 특수문자를 포함해야 합니다.")
private String password;
로그인 시도 지연(Throttling) : 로그인 실패 시 지연 시간을 점진적으로 증가시켜 공격 속도를 늦춥니다.
ex) 로그인 실패 시마다 지연 시간을 계산하여 응답을 지연시키거나, 스프링 필터를 통해 적용할 수 있습니다.
이중 인증(Multi-Factor Authentication, MFA) 도입 : 비밀번호 외에 추가적인 인증 수단(예: OTP, 인증 앱)을 요구하여 보안을 강화합니다.
ex) Spring Security와 연동하여 MFA를 구현하고 사용자가 로그인 시 추가 인증을 요구합니다.
IP 차단 및 모니터링 : 의심스러운 IP 주소에서의 반복적인 로그인 시도를 감지하고 차단합니다.
ex) Spring Security의 Event Listener를 사용하여 로그인 시도를 모니터링하고, 특정 조건에 따라 IP를 차단합니다.
비밀번호 해싱 및 보안 저장 : 비밀번호를 평문으로 저장하지 않고, 안전한 해시 알고리즘(예: bcrpyt, Argon2)으로 해싱하여 저장합니다.
물론입니다! 신입 Java/Spring 백엔드 개발자가 브루트 포스 공격 방어를 실습하면서 실제 프로젝트에 적용해볼 수 있는 여러 가지 과제를 제안드리겠습니다. 각 과제는 단계별로 진행할 수 있도록 설명드리며, 실습을 통해 보안 개념을 깊이 있게 이해하고, 실제 애플리케이션에 적용하는 능력을 키울 수 있습니다.
먼저, 실습을 진행할 기본적인 Spring Boot 프로젝트를 설정해야 합니다.
Spring Initializr 사용:
프로젝트 구조:
src/main/java/com/example/security/ 패키지 생성목표: 비밀번호를 안전하게 해싱하여 저장하고, 사용자가 등록할 수 있는 기능을 구현합니다.
실습 단계:
User 엔티티 생성:
@Entity
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 int failedLoginAttempts;
private boolean accountLocked;
private LocalDateTime lockTime;
// getters and setters
}
UserRepository 생성:
public interface UserRepository extends JpaRepository<User, Long> {
Optional<User> findByUsername(String username);
}
UserService 구현:
@Service
public class UserService implements UserDetailsService {
@Autowired
private UserRepository userRepository;
@Autowired
private BCryptPasswordEncoder passwordEncoder;
public User registerUser(String username, String password) {
User user = new User();
user.setUsername(username);
user.setPassword(passwordEncoder.encode(password));
return userRepository.save(user);
}
@Override
public UserDetails loadUserByUsername(String username) throws UsernameNotFoundException {
User user = userRepository.findByUsername(username)
.orElseThrow(() -> new UsernameNotFoundException("User not found"));
return new org.springframework.security.core.userdetails.User(
user.getUsername(),
user.getPassword(),
new ArrayList<>()
);
}
}
BCryptPasswordEncoder Bean 설정:
@Configuration
public class SecurityConfig {
@Bean
public BCryptPasswordEncoder passwordEncoder() {
return new BCryptPasswordEncoder();
}
// 추가 보안 설정
}
회원가입 및 로그인 폼 구현:
목표: 일정 횟수 이상 로그인 실패 시 계정을 잠그는 기능을 구현합니다.
실습 단계:
UserService 업데이트:
public void increaseFailedAttempts(User user) {
int newFailAttempts = user.getFailedLoginAttempts() + 1;
user.setFailedLoginAttempts(newFailAttempts);
userRepository.save(user);
}
public void resetFailedAttempts(User user) {
user.setFailedLoginAttempts(0);
userRepository.save(user);
}
public void lock(User user) {
user.setAccountLocked(true);
user.setLockTime(LocalDateTime.now());
userRepository.save(user);
}
public boolean unlockWhenTimeExpired(User user) {
LocalDateTime lockTime = user.getLockTime();
if (lockTime == null) return false;
LocalDateTime unlockTime = lockTime.plusMinutes(15);
if (LocalDateTime.now().isAfter(unlockTime)) {
user.setAccountLocked(false);
user.setLockTime(null);
user.setFailedLoginAttempts(0);
userRepository.save(user);
return true;
}
return false;
}
AuthenticationProvider 커스터마이징:
@Component
public class CustomAuthenticationProvider implements AuthenticationProvider {
@Autowired
private UserService userService;
@Autowired
private BCryptPasswordEncoder passwordEncoder;
@Override
public Authentication authenticate(Authentication authentication) throws AuthenticationException {
String username = authentication.getName();
String password = authentication.getCredentials().toString();
User user = userService.findByUsername(username)
.orElseThrow(() -> new BadCredentialsException("Invalid username or password"));
if (user.isAccountLocked()) {
if (userService.unlockWhenTimeExpired(user)) {
// 계정 잠금 해제됨
} else {
throw new LockedException("Your account has been locked due to multiple failed login attempts. Please try again later.");
}
}
if (passwordEncoder.matches(password, user.getPassword())) {
userService.resetFailedAttempts(user);
return new UsernamePasswordAuthenticationToken(username, password, new ArrayList<>());
} else {
userService.increaseFailedAttempts(user);
if (user.getFailedLoginAttempts() >= 5) {
userService.lock(user);
throw new LockedException("Your account has been locked due to multiple failed login attempts. Please try again later.");
}
throw new BadCredentialsException("Invalid username or password");
}
}
@Override
public boolean supports(Class<?> authentication) {
return authentication.equals(UsernamePasswordAuthenticationToken.class);
}
}
SecurityConfig 업데이트:
@Configuration
public class SecurityConfig extends WebSecurityConfigurerAdapter {
@Autowired
private CustomAuthenticationProvider authProvider;
@Override
protected void configure(AuthenticationManagerBuilder auth) throws Exception {
auth.authenticationProvider(authProvider);
}
@Override
protected void configure(HttpSecurity http) throws Exception {
http
.authorizeRequests()
.antMatchers("/register", "/login").permitAll()
.anyRequest().authenticated()
.and()
.formLogin()
.loginPage("/login")
.permitAll()
.and()
.logout()
.permitAll();
}
@Bean
public BCryptPasswordEncoder passwordEncoder() {
return new BCryptPasswordEncoder();
}
}
테스트:
목표: 자동화된 로그인 시도를 방지하기 위해 CAPTCHA를 로그인 폼에 추가합니다.
실습 단계:
Google reCAPTCHA 설정:
의존성 추가:
pom.xml에 HTTP 클라이언트 의존성 추가 (예: spring-boot-starter-web 포함).로그인 폼 수정:
<!-- login.html -->
<form action="/login" method="post">
<input type="text" name="username" placeholder="Username" required />
<input type="password" name="password" placeholder="Password" required />
<div class="g-recaptcha" data-sitekey="YOUR_SITE_KEY"></div>
<button type="submit">Login</button>
</form>
<script src="https://www.google.com/recaptcha/api.js" async defer></script>
서버 측 CAPTCHA 검증:
@Controller
public class AuthController {
@Value("${google.recaptcha.secret}")
private String recaptchaSecret;
@PostMapping("/login")
public String login(@RequestParam String username,
@RequestParam String password,
@RequestParam("g-recaptcha-response") String recaptchaResponse,
Model model) {
if (!verifyRecaptcha(recaptchaResponse)) {
model.addAttribute("error", "CAPTCHA verification failed.");
return "login";
}
// 기존 로그인 로직
}
private boolean verifyRecaptcha(String recaptchaResponse) {
RestTemplate restTemplate = new RestTemplate();
String url = "https://www.google.com/recaptcha/api/siteverify?secret=" + recaptchaSecret +
"&response=" + recaptchaResponse;
ReCaptchaResponse response = restTemplate.postForObject(url, null, ReCaptchaResponse.class);
return response != null && response.isSuccess();
}
}
public class ReCaptchaResponse {
private boolean success;
private List<String> errorCodes;
// getters and setters
public boolean isSuccess() {
return success;
}
public void setSuccess(boolean success) {
this.success = success;
}
public List<String> getErrorCodes() {
return errorCodes;
}
public void setErrorCodes(List<String> errorCodes) {
this.errorCodes = errorCodes;
}
}
설정 파일 업데이트:
# application.properties
google.recaptcha.secret=YOUR_SECRET_KEY
테스트:
목표: 사용자가 복잡한 비밀번호를 설정하도록 강제합니다.
실습 단계:
비밀번호 유효성 검증 애노테이션 추가:
public class UserRegistrationDto {
@NotBlank
private String username;
@NotBlank
@Pattern(regexp = "^(?=.*[A-Z])(?=.*[a-z])(?=.*\\d)(?=.*[@#$%^&+=]).{8,}$",
message = "비밀번호는 최소 8자 이상, 대문자, 소문자, 숫자, 특수문자를 포함해야 합니다.")
private String password;
// getters and setters
}
회원가입 컨트롤러 수정:
@PostMapping("/register")
public String register(@Valid @ModelAttribute("user") UserRegistrationDto userDto,
BindingResult result,
Model model) {
if (result.hasErrors()) {
return "register";
}
userService.registerUser(userDto.getUsername(), userDto.getPassword());
return "redirect:/login";
}
회원가입 폼 수정:
테스트:
목표: 로그인 실패 시 지연 시간을 점진적으로 증가시켜 공격 속도를 늦춥니다.
실습 단계:
User 엔티티에 지연 시간 필드 추가:
private int failedLoginAttempts;
private long lockTime; // 밀리초 단위
로그인 실패 시 지연 시간 적용:
public void increaseFailedAttempts(User user) {
int newFailAttempts = user.getFailedLoginAttempts() + 1;
user.setFailedLoginAttempts(newFailAttempts);
user.setLockTime(System.currentTimeMillis() + (long)Math.pow(2, newFailAttempts) * 1000); // 지연 시간 증가
userRepository.save(user);
}
public boolean isLocked(User user) {
if (user.getLockTime() == 0) return false;
if (System.currentTimeMillis() > user.getLockTime()) {
user.setFailedLoginAttempts(0);
user.setLockTime(0);
userRepository.save(user);
return false;
}
return true;
}
AuthenticationProvider 수정:
@Override
public Authentication authenticate(Authentication authentication) throws AuthenticationException {
String username = authentication.getName();
String password = authentication.getCredentials().toString();
User user = userService.findByUsername(username)
.orElseThrow(() -> new BadCredentialsException("Invalid username or password"));
if (userService.isLocked(user)) {
throw new LockedException("Your account is locked. Please try again later.");
}
if (passwordEncoder.matches(password, user.getPassword())) {
userService.resetFailedAttempts(user);
return new UsernamePasswordAuthenticationToken(username, password, new ArrayList<>());
} else {
userService.increaseFailedAttempts(user);
throw new BadCredentialsException("Invalid username or password");
}
}
SecurityConfig에 필터 추가하여 지연 시간 적용:
@Override
protected void configure(HttpSecurity http) throws Exception {
http
.authorizeRequests()
.antMatchers("/register", "/login").permitAll()
.anyRequest().authenticated()
.and()
.formLogin()
.loginPage("/login")
.permitAll()
.and()
.logout()
.permitAll()
.and()
.addFilterBefore(new ThrottlingFilter(), UsernamePasswordAuthenticationFilter.class);
}
public class ThrottlingFilter extends OncePerRequestFilter {
@Override
protected void doFilterInternal(HttpServletRequest request,
HttpServletResponse response,
FilterChain filterChain) throws ServletException, IOException {
// 로그인 시도 시 지연 시간 적용
if (request.getRequestURI().equals("/login") && request.getMethod().equalsIgnoreCase("POST")) {
// 사용자 정보 추출 및 지연 시간 적용 로직
// 예시: Thread.sleep(lockTime);
}
filterChain.doFilter(request, response);
}
}
테스트:
목표: 비밀번호 외에 추가적인 인증 수단(예: OTP)을 요구하여 보안을 강화합니다.
실습 단계:
OTP 라이브러리 추가:
pom.xml에 의존성 추가:<dependency>
<groupId>com.warrenstrange</groupId>
<artifactId>googleauth</artifactId>
<version>1.4.0</version>
</dependency>User 엔티티에 OTP 관련 필드 추가:
private String secretKey;
private boolean mfaEnabled;
OTP 생성 및 검증 로직 구현:
@Service
public class OTPService {
private final GoogleAuthenticator gAuth = new GoogleAuthenticator();
public String generateSecretKey() {
return gAuth.createCredentials().getKey();
}
public boolean verifyCode(String secret, int code) {
return gAuth.authorize(secret, code);
}
}
MFA 설정 컨트롤러 및 뷰 구현:
로그인 프로세스 수정:
SecurityConfig 업데이트:
@Override
protected void configure(HttpSecurity http) throws Exception {
http
.authorizeRequests()
.antMatchers("/register", "/login", "/mfa").permitAll()
.anyRequest().authenticated()
.and()
.formLogin()
.loginPage("/login")
.permitAll()
.and()
.logout()
.permitAll();
// 추가적인 MFA 필터 설정
}
테스트:
목표: 의심스러운 IP 주소에서의 반복적인 로그인 시도를 감지하고 차단합니다.
실습 단계:
FailedLoginAttempt 엔티티 생성:
@Entity
public class FailedLoginAttempt {
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
private Long id;
private String ipAddress;
private LocalDateTime attemptTime;
// getters and setters
}
FailedLoginAttemptRepository 생성:
public interface FailedLoginAttemptRepository extends JpaRepository<FailedLoginAttempt, Long> {
List<FailedLoginAttempt> findByIpAddressAndAttemptTimeAfter(String ip, LocalDateTime time);
}
LoginController에서 IP 기록 추가:
@Component
public class CustomAuthenticationProvider implements AuthenticationProvider {
@Autowired
private FailedLoginAttemptRepository failedLoginRepo;
// 기존 필드
@Override
public Authentication authenticate(Authentication authentication) throws AuthenticationException {
String username = authentication.getName();
String password = authentication.getCredentials().toString();
String ipAddress = // 클라이언트 IP 주소 추출
// IP 차단 로직
List<FailedLoginAttempt> recentAttempts = failedLoginRepo.findByIpAddressAndAttemptTimeAfter(
ipAddress, LocalDateTime.now().minusMinutes(15));
if (recentAttempts.size() >= 10) {
throw new LockedException("Too many failed attempts from your IP. Try again later.");
}
try {
// 기존 인증 로직
} catch (BadCredentialsException e) {
// 실패 시도 기록
FailedLoginAttempt attempt = new FailedLoginAttempt();
attempt.setIpAddress(ipAddress);
attempt.setAttemptTime(LocalDateTime.now());
failedLoginRepo.save(attempt);
throw e;
}
}
}
IP 차단 로직 강화:
테스트:
목표: 비정상적인 로그인 시도를 실시간으로 모니터링하고, 관리자에게 알림을 보내는 시스템을 구축합니다.
실습 단계:
로그 설정:
application.properties에 로깅 레벨 설정logging.level.org.springframework.security=DEBUG
logging.file.name=security.log로그 이벤트 리스너 구현:
@Component
public class AuthenticationEventListener {
private static final Logger logger = LoggerFactory.getLogger(AuthenticationEventListener.class);
@EventListener
public void onAuthenticationFailure(AbstractAuthenticationFailureEvent event) {
String username = event.getAuthentication().getName();
logger.warn("Failed login attempt for user: {}", username);
// 추가적인 알림 로직 (예: 이메일, Slack 등)
}
@EventListener
public void onAuthenticationSuccess(AuthenticationSuccessEvent event) {
String username = event.getAuthentication().getName();
logger.info("Successful login for user: {}", username);
}
}
알림 시스템 통합:
테스트:
목표: 앞서 구현한 모든 보안 기능을 통합하여 완전한 보안 시스템을 구축합니다.
실습 단계:
모든 기능 통합:
보안 테스트:
코드 리뷰 및 최적화:
문서화:
위의 실습 과제를 통해 브루트 포스 공격 방어를 위한 다양한 보안 메커니즘을 실제로 구현해보며, Java/Spring 백엔드 개발자로서의 보안 역량을 크게 향상시킬 수 있을 것입니다. 각 과제를 단계별로 진행하면서 필요에 따라 추가적인 학습 자료를 참고하시기 바랍니다. 화이팅입니다!