[LG CNS AM Inspire Camp 1기] SpringSecurity (2) - 커스텀 로그인 기능 구현

정성엽·2025년 2월 9일
2

LG CNS AM Inspire 1기

목록 보기
44/53

INTRO

이번 포스팅에서는 스프링 시큐리티(Spring Security)를 활용하여 커스텀 로그인 기능을 구현하는 방법을 정리해보려고 한다.

기본적으로 spring-boot-starter-security 의존성을 추가하면 모든 요청이 인증을 요구하며, /login 페이지를 기본 로그인 페이지로 제공된다는 내용을 이전 포스팅에서 정리했다.

하지만 실제 애플리케이션에서는 커스텀 로그인 페이지를 적용하는 경우가 많다.

이번 포스팅에서는 커스텀 로그인 페이지 적용, 회원 가입 기능, 비밀번호 해싱, 사용자 인증 처리까지 정리해보려고 한다 👀


1. 커스텀 로그인 기능 추가

이전 포스팅에서 SpringSecurity Configuration을 지정하는 파일을 하나 생성하고 관리했다.

이전에 SpringFilterChain 타입의 메서드를 빈으로 등록하여 인증 / 권한 부여 등의 커스터마이징이 가능하다고 설명한 바 있다.

마찬가지로 SpringFilterChain에서 로그인 기능을 추가해보자

Sample Code

@Configuration
@EnableWebSecurity
public class SecurityConfiguration {
    @Bean
    SecurityFilterChain securityFilterChain(HttpSecurity http) throws Exception {
        http
                .authorizeHttpRequests(auth -> auth
                        .requestMatchers("/", "/login", "/home").permitAll()
                        .requestMatchers("/board/**", "/api/**").hasAnyRole("ADMIN", "USER")
                        .anyRequest().authenticated()
                );

        http
                .formLogin(auth -> auth
                        .loginPage("/login")
                        .loginProcessingUrl("/loginProc")
                        .permitAll()
                );

        http.csrf(auth -> auth.disable()); // CSRF 보호 비활성화 (테스트용)

        return http.build();
    }
}

HttpSecurity 객체를 이용하여 로그인 기능과 관련된 기능을 추가했다.

.formLogin을 통해 폼 로그인 설정을 추가했으며 .loginPage 메서드를 통해 "/login"을 로그인 URL으로 설정했다.

다음으로 .loginProcessingUrl 메서드를 이용하여 실제 로그인 요청을 받을 URL을 설정해준다.

로그인의 경우 모든 사용자가 접근 가능하기 때문에 .permitAll() 을 통해 모든 접근을 허용하도록 설정하자!

💡 CSRF 보호와 로그인 설정

CSRF(Cross-Site Request Forgery)는 정상적인 요청인지를 확인하지 않고 처리했을 때 발생하는 위조/변조 문제를 방지하기 위한 보안 기능이다.

기본적으로 Spring Security는 CSRF 보호 기능이 활성화되어 있다.

커스텀 로그인 페이지를 적용하면서 CSRF 보호를 비활성화할 수도 있으며, 테스트 환경에서는 이를 끄는 경우가 많다.

왜나하면, CSRF 보호가 활성화된 상태에서는 Swagger, Postman 등의 API 테스트 도구를 사용할 때 CSRF 토큰을 수동으로 포함해야 한다.

따라서, 테스트를 진행하면서 개발을 진행하기 때문에 개발 속도를 저하시키기 때문에 개발 환경에서는 끄도록 하자!

💡 로그인 요청 처리는 어디에서?

우선 필자가 설정한 컨트롤러를 살펴보자

Sample Code

@Controller
public class LoginController {
    @GetMapping("/login")
    public String login() {
        return "/login";
    }
}

코드를 보다시피, /login URL 요청에 대해서만 View를 보여주고 있다.

즉, 이전에 설정한 로그인을 실질적으로 처리하는 URL인 /loginProc 에 대해서는 어떤 코드도 작성하지 않았다.

하지만 로그인은 요청은 정상적으로 동작하게 된다.

그 이유는 뭘까?

🚀 loginProcessingUrl("/loginProc")

우리는 로그인 관련 설정을 SecurityFilterChain 메서드에서 설정했다.

따라서, 인증 / 권한 확인과 동일하게 Filter를 사용하여 요청 정보에 대한 검증을 수행할 수 있다.

Spring Security는 로그인 요청을 처리하는 기본 필터인 UsernamePasswordAuthenticationFilter 를 내부적으로 제공한다.

이 필터는 loginProcessingUrl("/loginProc") 을 설정하면 해당 URL로 들어오는 POST 요청을 자동으로 가로채어 로그인 처리를 수행한다!

즉, /loginProc을 위한 컨트롤러를 따로 구현하지 않아도 Spring Security가 자동으로 다음 과정을 처리하는 것이다.

(물론 직접 컨트롤러에 구현할 수 있으며, 당연히 직접 구현한다면 구현체가 실행된다)


2. 회원 가입 기능 추가

로그인 기능이 있다면 회원가입 기능도 동시에 제공해야 한다.

우선, 회원 가입 기능을 추가하기 전에 간단하게 해싱에 대해서 정리해보자

💡 비밀번호 해싱(Hashing)

암호학에서 사용하는 암호화와 해싱의 차이점은 해싱은 단방향 처리라는 것이다.

일반적으로 키를 사용한 암호화 방식의 경우, 키를 가지고 있다면 암호화된 내용을 복호화하여 원본 데이터를 가져올 수 있다.

반면, 해싱은 단방향 처리이므로 암호화된 내용을 가지고 있더라도 원본 데이터를 가져올 수 없다!

💡 BCryptPasswordEncoder

SpringSecurity에서는 해싱 암호화를 위해 BCryptPasswordEncoder 객체를 제공한다.

개발자는 간단하게 이를 빈으로 등록하고 실제 사용하는 곳에서는 호출하여 간단하게 데이터를 해싱할 수 있다.

우선 코드를 살펴보자

Sample Code

@Configuration
@EnableWebSecurity
public class SecurityConfiguration {
    @Bean
    SecurityFilterChain securityFilterChain(HttpSecurity http) throws Exception {
		...
    }

    @Bean
    BCryptPasswordEncoder bCryptPasswordEncoder() {
        return new BCryptPasswordEncoder();
    }
}

필자는 기존에 Security 설정을 진행했던 Configuration 파일에서 bCryptPasswordEncoder 객체를 빈으로 등록했다.

이제 빈으로 등록된 객체를 실제로 사용한다면 다음과 같다.

Sample Code

@Service
public class JoinServiceImpl implements JoinService {
    private BCryptPasswordEncoder bCryptPasswordEncoder;
    private UserRepository userRepository;

    public JoinServiceImpl(BCryptPasswordEncoder bCryptPasswordEncoder, UserRepository userRepository) {
        this.bCryptPasswordEncoder = bCryptPasswordEncoder;
        this.userRepository = userRepository;
    }

    @Override
    public boolean joinProcess(JoinDto joinDto) {
        if (userRepository.existsByUsername(joinDto.getUsername())) {
            return false;
        }

        if (!joinDto.checkPassword()) {
            return false;
        }

        UserEntity userEntity = new ModelMapper().map(joinDto, UserEntity.class);
        userEntity.setPassword(bCryptPasswordEncoder.encode(userEntity.getPassword()));
        userEntity.setRole("ROLE_USER");

        try {
            userRepository.save(userEntity);
        } catch (Exception e) {
            return false;
        }
        return true;
    }
}

JoinServiceImpl은 회원가입 기능을 제공하는 서비스 레이어의 클래스이다.

여기서는 사용자가 회원가입시 입력한 비밀번호를 해싱하여 DB에 저장해야 한다.

따라서, 이전에 빈으로 등록했던 BCryptPasswordEncoder 를 의존성 주입으로 가져와서 사용하고 있는 모습을 볼 수 있다!

💡 JoinController

회원가입 기능은 로그인과는 다르게 필터를 통해 권한을 확인하지 않는다.

즉, 개발자가 직접 개발을 해야하기 때문에 컨트롤러에 대한 요청도 처리를 해야한다.

간단하게 필자가 작성한 코드를 첨부하고 넘어가자

Sample Code

@Controller
public class JoinController {
    @Autowired
    JoinService joinService;

    @GetMapping("/join")
    public String join() {
        return "/join";
    }

    @PostMapping("/joinProc")
    public String joinProc(JoinDto joinDto) {
        if (joinService.joinProcess(joinDto)) {
            return "redirect:/login";
        } else {
            return "redirect:/join";
        }
    }
}

3. UserDetails & UserDetailsService

회원가입 / 로그인을 수행하기 위해서는 유저 정보를 저장할 DB TABLE이 필요하다.

간단하게 UserEntity를 만들어보자

Sample Code

@Entity
@Table(name = "t_jpa_user")
@Data
public class UserEntity {
    @Id
    @GeneratedValue(strategy = GenerationType.IDENTITY)
    private int seq;

    @Column(unique = true)
    private String username;

    private String password;
    private String name;
    private String email;

    private String role;
}

다음으로 유저 정보를 DB에 접근해서 가져온다면 저장해야할 객체가 필요할 것이다.

하지만 Spring Security는 자체적인 인증 처리 방식을 가지고 있기 때문에, 우리가 만든 UserEntity를 Spring Security가 이해할 수 있는 형태로 변환해야 한다.

이를 위해 UserDetails와 UserDetailsService 인터페이스를 사용한다.

💡 UserDetails 인터페이스

Spring Security에서 UserDetails 인터페이스는 사용자의 인증 정보를 저장하고 관리하기 위한 표준 인터페이스이다.

이 인터페이스를 구현하여 스프링 시큐리티의 인증 과정에서 사용자 정보를 활용할 수 있도록 해보자

Sample Code

public class CustomUserDetails implements UserDetails {
    private UserEntity userEntity;

    public CustomUserDetails(UserEntity userEntity) {
        this.userEntity = userEntity;
    }

    @Override
    public Collection<? extends GrantedAuthority> getAuthorities() {
        Collection<GrantedAuthority> collection = new ArrayList<>();
        collection.add(new GrantedAuthority() {
            // 우리가 가지고 있는 Entity의 Role을 리턴해준다.
            @Override
            public String getAuthority() {
                return userEntity.getRole();
            }
        });
        return collection;
    }

    @Override
    public String getPassword() {
        return userEntity.getPassword();
    }

    @Override
    public String getUsername() {
        return userEntity.getUsername();
    }
}

UserDetails 인터페이스는 Spring Security에서 사용자 인증 정보를 표현하는 인터페이스다.

우리는 이 인터페이스를 구현하여 UserEntity의 정보를 Spring Security에서 사용할 수 있는 형태로 변환한다.

특히 getAuthorities(), getPassword(), getUsername() 메서드는 인증에 필수적인 정보를 제공한다!

💡 UserDeatilService 인터페이스

UserDetails는 같이 사용되는 인터페이스가 있다.

바로 UserDetailService인데, 사용자 정보를 조회하는 기능을 해당 인터페이스를 상속하여 구현하면 된다.

우선 코드를 먼저 살펴보자

Sample Code

@Service
public class CustomUserDetailsService implements UserDetailsService {
    @Autowired
    private UserRepository userRepository;

    @Override
    public UserDetails loadUserByUsername(String username) throws UsernameNotFoundException {
        UserEntity userEntity = userRepository.findByUsername(username);
        if (userEntity == null) {
            throw new UsernameNotFoundException("등록된 사용자가 없습니다.");
        }
        return new CustomUserDetails(userEntity);
    }
}

UserDetailService 인터페이스를 상속하면 loadUserByUsername 이라는 메서드를 오버라이딩 해야한다.

이름에 맞게 아이디를 기반으로 DB에서 유저 정보를 가지고오는 코드를 작성해주면 된다.

여기서 리턴 타입은 UserDeatils라는 것을 기억하자!

SpringSecurity가 로그인 요청을 받으면 자동으로 loadUserByUsername() 메서드를 호출하여 사용자 정보를 가져온다.

즉, 이번에 생성한 CustomUserDeatilsService는 빈으로만 등록해주면 SpringSecurity에 의해 내부적으로 자동 호출되는 기능이라는 것을 기억하자 📖

💡 동작 흐름

마지막으로 이 동작 흐름을 정리해보자

동작 흐름
1. 사용자가 로그인 폼에서 username/password를 입력하여 로그인 요청을 보냄 (/loginProc)

  1. UsernamePasswordAuthenticationFilter 가 요청을 가로채서 AuthenticationManager 에 인증 요청을 전달
  1. AuthenticationManager 는 등록된 UserDetailsService의 loadUserByUsername() 메서드를 호출하여 DB에서 사용자 정보를 조회
  1. 조회된 사용자 정보(UserDetails)와 입력된 패스워드를 비교 후 인증 성공 시 SecurityContextHolder 에 저장
  1. 이후 요청이 들어올 때 SecurityContext 에서 사용자 정보를 가져와 인증된 사용자로 인식

정리하자면 Spring Security는 UserDetails 객체를 기반으로 인증을 수행하는데, 이 객체를 생성하는 표준 인터페이스가 UserDetailsService 인 것이다.

UserDetailsService 를 구현하면 어떤 데이터베이스든 사용자의 인증 정보를 조회하는 방식을 정의할 수 있다.

Spring Security는 기본적으로 UserDetailsService 를 사용하여 로그인 요청을 처리할 때 자동으로 이 메서드를 호출하여 사용자 정보를 가져오는 것이다 👊


4. SuccessHandler

로그인에 성공하면 개발자는 어떤 작업을 할 수 있을까?

기본적으로 로그인에 성공하면 메인 페이지로 리다이렉션을 해줘야 할 것이다.

또한, 유저 정보를 세션에 저장한다면 어디에서든지 세션에서 데이터를 가져와 유저 정보를 사용할 수 있을 것이다.

이처럼 로그인이 성공된 이후, 특정 작업이 추가로 필요하다면 SuccessHandler를 생성하여 추가할 수 있다.

우선 코드를 살펴보자

Sample Code

@Component
public class CustomAuthenticationSuccessHandler implements AuthenticationSuccessHandler {
    @Autowired
    private UserRepository userRepository;

    // 로그인을 성공하면 onAuthenticationSuccess를 호출한다.
    @Override
    public void onAuthenticationSuccess(HttpServletRequest request,
                HttpServletResponse response, Authentication authentication) throws IOException, ServletException {
        UserDetails userDetails = (UserDetails) authentication.getPrincipal(); // 로그인 성공시 UserDetail을 가져올 수 있음
        UserEntity userEntity = userRepository.findByUsername(userDetails.getUsername());

        request.getSession().setAttribute("user", userEntity);
        response.sendRedirect("/");
    }
}

로그인에 성공한 후, 특정 작업을 해야한다면 AuthenticationSuccessHandler 인터페이스를 상속하여 구현체를 우선 만들어주면 된다.

해당 구현체에서는 유저 정보를 가져와서 세션에 저장하고 메인 페이지로 리다이렉션하는 내용이 코드로 작성되어 있다.

다음으로 이 핸들러를 이전에 설정한 설정 파일에 추가해주면 된다.

Sample Code

@Configuration
@EnableWebSecurity
public class SecurityConfiguration {
    @Autowired
    private CustomAuthenticationSuccessHandler successHandler;
?
    @Bean
    SecurityFilterChain securityFilterChain(HttpSecurity http) throws Exception {
        http
                .authorizeHttpRequests(auth -> auth
                        .requestMatchers("/", "/login", "/home", "/join", "/joinProc").permitAll()
                        .requestMatchers( "/board/**", "/api/**" ).hasAnyRole("ADMIN", "USER")
                        .anyRequest().authenticated()
                );

        http
                .formLogin(auth -> auth
                                .loginPage("/login")
                                .loginProcessingUrl("/loginProc")
                                .permitAll()
                                .successHandler(successHandler)
                );

        http.csrf(auth -> auth.disable());

        return http.build();
    }
    ...
}

.formLogin 메서드로 로그인 작업을 설정한 부분에서 이전에 생성한 successHandler를 등록해주면, 로그인 성공시 successHandler에서 정의된 코드가 실행된다!


OUTRO

이번 포스팅에서는 Spring Security를 활용하여 커스텀 로그인 기능을 구현하는 방법을 정리해봤다.

뭔가 깔끔하게 정리하고 싶었는데 주저리주저리 정리한 것 같은 느낌이 든다.

간단하게 SpringSecurity에서는 로그인 요청이 들어오면 내부적으로 사용자 검증을 위해 UserDetailsService의 loadUserByUsername을 통해 반환된 UserDetail과 입력 내용을 비교한다.

DB에 저장된 유저 정보와 일치하면 세션 기반의 Context에 저장하여 유저 정보를 사용할 수 있도록 한다.

마지막으로 successHandler를 별도로 정의하면 로그인이 성공한 이후 수행해야할 작업을 커스터마이징할 수 있다는 것까지 기억하자 👊

profile
코린이

0개의 댓글

관련 채용 정보