Spring Boot Spring Security(form login)

Kang.__.Mingu·2025년 1월 13일

Spring Boot

목록 보기
6/8

Spring Security

스프링 시큐리티는 스프링 기반의 애플리케이션 보안(인증, 인가, 권한)을 담당하는 스프링 하위 프레임워크이다.

인증(Authentication)

  • 인증은 사용자의 신원을 입증하는 과정
  • EX) 사용자가 사이트에 로그인을 할 때 누구인지 확인하는 과정

인가(Authorization)

  • 특정 사이트에 접근할 수 있는 권한을 확인하는 작업
  • EX) 관리자는 관리자 페이지 접근 가능하지만 사용자는 관리자 권한을 가지고 있지 않기 때문에 불가능

Spring Security를 이용하면 CSRF 공격, Session 고정 공격을 방어해주고, 요청 헤더도 보안 처리를 해준다.

CSRF 공격

  • 사용자의 권한을 가지고 특정 동작을 수행하도록 유도하는 공격

Session 고정 공격

  • 사용자의 인증 정보를 탈취하거나 변조하는 공격

Start!

Spring Security 의존성 추가하기

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

User 엔티티 만들기

  • 기존에 만들던 방식으로 만들기
  • UsersEntity 클래스에 UserDetails 인터페이스를 구현(implements)하여 Spring Security의 인증 객체로 사용해야한다.
@ToString
@Entity
@Table(name = "users") // 테이블 이름 지정
@Data // Lombok: Getter, Setter, toString, equals, hashCode 생성
public class UsersEntity implements UserDetails {
    // 기본키
    // 시퀀스=> 자동 증가값
    /* 기본 값 지정 */
    @Id
    @GeneratedValue(strategy = GenerationType.IDENTITY) // AUTO_INCREMENT 설정
    private Long id;

    /* 사용자 이름 */
    @Column(nullable = false, length = 50) // NOT NULL, 길이 제한, 고유 값 설정
    private String username;

    /* 사용자 이메일 */
    @Column(nullable = false, length = 100) // NOT NULL, 길이 제한
    private String email;

    /* 사용자 id */
    @Column(nullable = false, length = 50)
    private String userid;

    /* 사용자 비밀번호 */
    @Column(nullable = false, length = 255) // NOT NULL
    private String password;

    /* 사용자 권한 */
    @Column(nullable = false, length = 10) // NOT NULL, 길이 제한
    private String role;

    /* 활성화 상태 */
    @Column(nullable = false) // NOT NULL
    private boolean enabled;

    /* 생성일 */
    @CreationTimestamp
    @Column(nullable = false, updatable = false) // NOT NULL, 수정 불가
    private LocalDateTime createdAt;

    /* Entity 객체를 DTO 객체로 변환하여 반환하는 메소드 */
    /* Select 명령 사용시 호출 */
    public UsersDTO toUserDTO() {
        UsersDTO usersDTO = new UsersDTO();
        usersDTO.setId(id);
        usersDTO.setUsername(username);
        usersDTO.setEmail(email);
        usersDTO.setUserid(userid);
        usersDTO.setPassword(password);
        usersDTO.setRole(role);
        usersDTO.setEnabled(enabled);
        usersDTO.setCreatedAt(createdAt);
        return usersDTO;
    }

    /* 권한 반환 */
    @Override
    public Collection<? extends GrantedAuthority> getAuthorities() {
        /* 최소한의 권한 정보가 필요 */
        /* ROLE_USER, ROLE_ADMIN */
        return List.of(new SimpleGrantedAuthority(role));
    }

    /* 계정 만료 여부 반환 */
    @Override
    public boolean isAccountNonExpired() {
        /* 만료되었는지 확인하는 로직 */
        return true; // true -> 만료되지 않았음
    }

    /* 계정 잠금 여부 반환 */
    @Override
    public boolean isAccountNonLocked() {
        /* 계정 잠금되었는지 확이니하는 로직 */
        return true; // -> true -> 잠금되지 않았음
    }

    /* 패스워드의 만료 여부 반환 */
    @Override
    public boolean isCredentialsNonExpired() {
        /* 패스워드가 만료되었는지 확인하는 로직 */
        return true; // -> 만료되지 않았음
    }
}

User DTO 만들기

  • User 엔티티랑 타입 똑같이 작성하면 됨
@Data
@Slf4j
@ToString
public class UsersDTO {
    private Long id;
    private String username;
    private String email;
    private String userid;
    private String password;
    private String role;
    private Boolean enabled;
    private LocalDateTime createdAt;

    /* DTO 객체를 Entity 객체로 변환하여 반환하는 메소드 */
    /* Insert 명령 또는 Update 명령 사용시 호출 */
    public UsersEntity toUsersEntity() {
        UsersEntity usersEntity = new UsersEntity();
        usersEntity.setId(id);
        usersEntity.setUsername(username);
        usersEntity.setEmail(email);
        usersEntity.setUserid(userid);
        usersEntity.setPassword(password);
        usersEntity.setRole(role);
        usersEntity.setEnabled(enabled);
        usersEntity.setCreatedAt(createdAt);
        return usersEntity;
    }
}

UserRepository 인터페이스 파일 생성

  • JpaRepository<엔티티, 엔티티의 기본키 타입>을 상속 받아야 한다.
  • 이번 코드에서는 userid로 사용자 정보를 가져올 것이다.(email로 받든 상황에 따라서 원하는 것로 받으면 된다.)
public interface UsersRepository extends JpaRepository<UsersEntity, Long> {
    Optional<UsersEntity> findByUserid(String userid);
}

UserDetailService 서비스 생성

  • UserDetailService 클래스에 UserDetailsService 인터페이스를 구현(implements)하여 Spring Security에서 사용자 정보를 가져온다.
  • loadUserByUsername() 메서드를 오버라이딩해서 사용자 정보를 가져온다.
@Slf4j
@Service
@RequiredArgsConstructor
public class UserDetailService implements UserDetailsService {
    /* Repository에서 사용자 정보를 가져와야됨 */
    private final UsersRepository usersRepository;

    /* 사용자 이름(userid)로 사용자의 정보를 가져오는 메서드 */
    @Override
    public UserDetails loadUserByUsername(String userid) throws UsernameNotFoundException {
        return usersRepository.findByUserid(userid)
                .orElseThrow(() -> new UsernameNotFoundException("유저 정보를 찾지 못했습니다.: " + userid));
    }
}

WebSecurityConfig 파일에 시큐리티 설정

  • config 패키지를 만들어서 해당 파일 생성
    작업 순서
  1. 패스워드 인코더로 사용할 빈 등록(BCryPasswordEncoder)
  2. SecurityFilterChain 설정(특정 HTTP 요청에 대한 웹 기반 보안 구성)
  3. AuthenticationManager 인증 관리자 관련 설정

.loginProcessingUrl("경로"): 이 메서드 때문에 3시간을 고생했다.
=> 커스텀 로그인 페이지를 설정해주고 해당 로그인 경로를 form에서 post 방식으로 요청하면 시큐리티가 낚아채서 대신 로그인을 진행한다.

@Configuration
@EnableWebSecurity
@RequiredArgsConstructor
public class WebSecurityConfig {
    /* 패스워드 인코더로 사용할 빈 등록 */
    @Bean
    public BCryptPasswordEncoder passwordEncoder() {
        return new BCryptPasswordEncoder();
    }

    /* 특정 HTTP 요청에 대한 웹 기반 보안 구성 */
    @Bean
    public SecurityFilterChain securityFilterChain(HttpSecurity http, CustomAuthenticationFailureHandler customAuthenticationFailureHandler) throws Exception {
        return http
                // authorizeHttpRequests: HTTP 요청에 대한 보안 규칙을 정의한다.
                .authorizeHttpRequests((auth) -> auth
                                // requestMatchers().permitAll(): 해당 경로들에 대해 모든 사용자의 접근을 허용한다.(인증없이)
                                .requestMatchers("/", "/login", "/signup").permitAll()
                                // requestMatchers().hasRole(): 해당 역할을 가진 사람만 접근할 수 잇다.
                                .requestMatchers("/admin").hasRole("ADMIN")
                                // requestMatchers().hasAnyRole(): 해당 경로에는 "USER" 또는 "ADMIN"역할을 가진 사용자가 접근할 수 있다.
                                .requestMatchers("/user/**").permitAll() //hasAnyRole("USER", "ADMIN")
                        // anyRequest().authenticated(): 이외의 모든 접근에 대해서는 인증을 진행한다.
                        //.anyRequest().authenticated()
                ).formLogin(form -> form
                        .loginPage("/login") // 커스텀 로그인 페이지
                        .loginProcessingUrl("/login") // login 주소가 호출되면 시큐리티가 낚에채서 대신 로그인 진행
                        .defaultSuccessUrl("/") // 로그인 성공시 이동 페이지
                        .usernameParameter("userid") // 아이디 파라미터 이름 변경
                        .failureHandler(customAuthenticationFailureHandler)  // ✅ 실패 핸들러 등록
                        .permitAll())
                        .logout(logout -> logout
                        .logoutUrl("/logout")
                        .invalidateHttpSession(true) // 로그아웃 성공시 세션 삭제 여부
                        .logoutSuccessUrl("/")).csrf(AbstractHttpConfigurer::disable) // 개발할 때는 비활성화 -> 배포시 활성화
                .build();

        // 기본 HTTP 인증을 사용
        //http.httpBasic(Customizer.withDefaults());
        // 세션 관리 설정
        //http.sessionManagement(session -> session.sessionCreationPolicy(SessionCreationPolicy.STATELESS));
        // h2 DB를 사용하는 경우 프레임이 안보이는 문제를 해결하기 위한 코드
        //http.headers().frameOptions().sameOrigin();
    }

    /* 인증 관리자 관련 설정 */
    @Bean
    public AuthenticationManager authenticationManager(HttpSecurity http, BCryptPasswordEncoder bCryptPasswordEncoder, UserDetailService userDetailService) throws Exception {
        /*
        DB 기반 인증
        UserDetailService와 PasswordEncoder를 연결하여 인증처리
        */
        DaoAuthenticationProvider authProvider = new DaoAuthenticationProvider();
        authProvider.setUserDetailsService(userDetailService);
        authProvider.setPasswordEncoder(bCryptPasswordEncoder);
        /* 여러 인증 제공자(Authentication Provider)를 관리하는 객체 */
        return new ProviderManager(authProvider);
    }
}

🔑 1️⃣ @Configuration

✅ 역할
Spring 설정 파일임을 명시
빈(Bean)을 등록하기 위해 사용한다.
내부에 정의된 메서드(@Bean)들이 스프링 컨테이너에 자동으로 등록된다.

🔎 쉽게 이해하기
Java 기반 설정 파일이라는 뜻!
XML 설정(applicationContext.xml)을 대체하는 자바 기반 설정이다.

🔑 2️⃣ @EnableWebSecurity

✅ 역할
Spring Security를 활성화한다.
기존의 Spring Boot 기본 보안 설정을 커스텀할 수 있도록 해준다.
Spring Security의 설정을 적용하기 위해 필요한 필수 어노테이션!!

🔎 쉽게 이해하기
"Spring Security 기능을 켜줘!"라는 의미
SecurityFilterChain을 사용해 직접 보안 설정을 구성할 수 있도록 해준다.

✅ 내부 동작
Spring Security 필터를 등록.
보안을 위한 인터셉터 및 인증/인가 설정을 적용할 수 있게 한다.

📍 중요
@EnableWebSecurity가 있어야 SecurityFilterChain 설정이 적용된다.
만약 없다면, Spring Security의 기본 설정이 적용되고, 커스텀 보안 설정이 무시된다.

🔑 3️⃣ AuthenticationManager란?

✅ AuthenticationManager의 역할
Spring Security의 인증(Authentication)을 총괄하는 인터페이스이다.
로그인 요청이 들어오면 아이디와 비밀번호가 맞는지 확인한다.
다양한 인증 방식(DB, 소셜 로그인 등)을 처리할 수 있도록 여러 인증 제공자(AuthenticationProvider)를 관리한다.

🔎 흐름
로그인 시, 사용자가 입력한 아이디와 비밀번호를 받아서
AuthenticationManager가 인증 요청을 처리힌디.
이 처리를 AuthenticationProvider에게 위임한다.

🔑 4️⃣ DaoAuthenticationProvider란?

✅ DaoAuthenticationProvider의 역할
DB 기반 인증을 담당하는 기본 제공 인증 제공자이다.
사용자가 입력한 아이디와 비밀번호를 DB에 저장된 값과 비교해서 인증한다.

✅ 내부 처리 흐름
UserDetailsService를 통해 DB에서 사용자 정보를 조회
PasswordEncoder(예: BCrypt)로 입력한 비밀번호와 DB의 비밀번호를 비교한다.
둘 다 일치하면 인증 성공, 아니면 인증 실패 처리한다.

🔎 정리
아이디 조회 → 비밀번호 비교 → 인증 여부 결정
이 과정을 자동으로 처리해준다.

🔑 5️⃣ ProviderManager란?

✅ ProviderManager의 역할
여러 인증 제공자(AuthenticationProvider)를 관리하는 객체이다.
AuthenticationManager의 기본 구현체.
여러 인증 방식(DB, 소셜 로그인, JWT 등)을 사용할 때,
각각의 인증 제공자를 순서대로 검사한다.

🔎 흐름
로그인 요청 → ProviderManager에게 전달
등록된 AuthenticationProvider(여러 개 가능)가 순차적으로 인증 시도
인증에 성공하면 인증 완료, 실패하면 다음 제공자가 인증 시도

🔥 한 문장으로 요약
AuthenticationManager는 로그인 요청이 오면 DaoAuthenticationProvider를 통해
DB에서 사용자 정보를 조회하고, 비밀번호를 검증해서 인증을 처리한다.
이 전체 과정을 ProviderManager가 통합적으로 관리!!

UserService 인터페이스 생성

public interface UsersService {
    /* 유저 추가 */
    void addUser(UsersDTO usersDTO);
}

UserServiceImpl 클래스 생성

@Slf4j
@Service
@RequiredArgsConstructor
public class UsersServiceImpl implements UsersService{
    private final UsersRepository usersRepository;
    private final BCryptPasswordEncoder bCryptPasswordEncoder;
    
        /* 유저 추가 */
    @Override
    public void addUser(UsersDTO usersDTO) {
        UsersEntity usersEntity = usersDTO.toUsersEntity();
        /* BCryptPasswordEncoder로 비밀번호를 암호화하여 저장 */
        usersEntity.setPassword(bCryptPasswordEncoder.encode(usersEntity.getPassword()));
        UsersEntity savedUsers = usersRepository.save(usersEntity);
        log.info(savedUsers.toString());
    }
}

Controller 클래스 생성

  • /login 로그인 쪽의 @RequestParam은 없어도 된다.
  • @RequestParam 받은 이유는 로그인 실패시 에러와 메세지를 받기 위해 써준것
@Slf4j
@Controller
@RequiredArgsConstructor
public class SecurityController {
    private final UsersService usersService;

    @GetMapping("/")
    public String index(Model model) {
        /*Authentication auth = SecurityContextHolder.getContext().getAuthentication();
        UsersEntity user = (UsersEntity) auth.getPrincipal();
        model.addAttribute("user", user);
        log.info("로그인 사용자 정보: "+user.toString());*/
        return "index";
    }

    /* 로그인 */
    @GetMapping("/login")
    public String login(@RequestParam(value = "error", required = false) String error, 
    @RequestParam(value = "message", required = false)String message, Model model) {
        model.addAttribute("error", error);
        model.addAttribute("message", message);
        return "pages/security/login";
    }

    /* 회원가입 */
    @GetMapping("/signup")
    public String signup(){
        return "pages/security/signup";
    }

    @PostMapping("/signup")
    public String sign(@ModelAttribute UsersDTO user) {
        user.setRole("ROLE_USER");
        user.setEnabled(true);
        log.info(user.toString());
        usersService.addUser(user);

        return "redirect:/user/login";
    }

    @GetMapping("/logout")
    public String logout(HttpServletRequest request, HttpServletResponse response) {
        new SecurityContextLogoutHandler().logout(request, response, SecurityContextHolder.getContext().getAuthentication());
        return "redirect:/user/login";
    }
}

회원가입과 로그인 페이지는 자기 맘대루~

로그인한 사용자, 비로그인 사용자, 권한에 따라 구분하는 법

<!DOCTYPE html>
<html lang="en"
      xmlns:th="http://www.thymeleaf.org"
      xmlns:layout="http://ultraq.net.nz/thymeleaf/layout"
      xmlns:sec="http://www.thymeleaf.org/extras/spring-security"
      layout:decorate="~{common/layouts/defaultLayout}"
>
<body>
    <!-- ✅ 로그인한 사용자일 때 -->
    <div sec:authorize="isAuthenticated()">
        <p>🔒 로그인한 사용자 있음</p>
        <p>사용자 아이디: <span sec:authentication="principal.username"></span></p>
        <p>사용자 이메일: <span sec:authentication="principal.email"></span></p>
        <a href="/logout">로그아웃</a>
    </div>

    <!-- ✅ 로그인하지 않은 사용자일 때 -->
    <div sec:authorize="isAnonymous()">
        <p>📝 메인페이지 (로그인하지 않은 사용자)</p>
        <a href="/login">로그인</a>
        <a href="/signup">회원가입</a>
    </div>
  
      <!-- ✅ ROLE_USER 권한일 때 -->
    <div sec:authorize="hasRole('ROLE_USER')">
        <h2>사용자 페이지</h2>
        <p>일반 사용자 전용 메뉴입니다.</p>
    </div>

    <!-- ✅ ROLE_ADMIN 또는 ROLE_MANAGER 권한이 있는 경우 -->
    <div sec:authorize="hasAnyRole('ROLE_ADMIN', 'ROLE_MANAGER')">
        <h2>관리자 및 매니저 페이지</h2>
    </div>
</body>

로그인 실패 커스텀 핸들러

@Component
public class CustomAuthenticationFailureHandler extends SimpleUrlAuthenticationFailureHandler {

    @Override
    public void onAuthenticationFailure(HttpServletRequest request, HttpServletResponse response, AuthenticationException exception)
            throws IOException, ServletException {

        String errorMessage;

        if (exception instanceof DisabledException) {
            errorMessage = "계정이 비활성화되었습니다. 관리자에게 문의하세요.";
        } else if (exception instanceof BadCredentialsException) {
            errorMessage = "아이디 또는 비밀번호가 잘못되었습니다.";
        } else {
            errorMessage = "로그인에 실패했습니다. 다시 시도해주세요.";
        }

        // ✅ Redirect로 에러 메시지 전달
        setDefaultFailureUrl("/login?error=true&message=" + URLEncoder.encode(errorMessage, "UTF-8"));
        super.onAuthenticationFailure(request, response, exception);
    }
}
profile
최선을 다해 꾸준히 노력하는 개발자 망고입니당 :D

0개의 댓글