[Spring Boot] 스프링 시큐리티 세션로그인 구현 방법

Juice-Han·2024년 10월 31일
0

Spring Boot

목록 보기
2/3
post-thumbnail

스프링 시큐리티를 적용하며 복잡하게 느껴졌던 세션 로그인 구현 과정을 정리해보고자 글을 작성하게 되었습니다. 다음은 제가 프로젝트에 적용했던 버전 정보입니다.

Spring Boot : 3.3.5 버전
Java : 17 버전

📕 스프링 시큐리티란?

인증&인가를 편하게 구현할 수 있도록 스프링에서 제공해주는 보안 관련 프레임워크입니다.

📗 인증과 인가(Authentication& Authorization)

인증과 인가라는 단어를 자주 들어보셨을테지만 헷갈리는 개념이라 짚고 넘어가겠습니다.

인증(Authentication) : 사용자가 누구인지 검증하는 과정
인가(Authorization) : 사용자의 역할에 따라 권한을 부여하는 과정

서버를 회사라고 비유해보겠습니다.

회사 출입 카드를 사용하여 회사에 들어가는 과정 = 인증
직급에 따라 출입가능한 사무실이 다르게 배치되는 것 = 인가

이렇게 생각하면 이해하기 쉬울 겁니다.

스프링 시큐리티 인증 과정

전체적인 인증 과정을 살펴보겠습니다.

스프링 시큐리티 인증 내부 구조

스프링 시큐리티의 인증 내부 구조입니다. 간단한 흐름은 다음과 같습니다.

  1. 사용자가 로그인 요청을 보냈을 때 인증 필터가 요청을 받습니다.
  2. 인증 매니저에게 요청과 관련된 유저 정보를 가져오도록 합니다.
  3. UserDetailsService에서 유저 정보를 찾아 인증 매니저에게 전달합니다.
  4. 요청 정보와 찾아낸 유저 정보를 비교하여 일치한다면 인증 객체를 생성하고
    SecurityContext에 저장합니다. (= 세션 저장)

📕 작성할 코드

내부구조를 보면 엄청 복잡해보이지만 사실 구현할게 그리 많지 않습니다.

📗 추가할 의존성(dependency)

dependencies {
    implementation 'org.springframework.boot:spring-boot-starter-security'
}

spring-boot-starter-security 의존성 하나만 의존성 정보에 추가해주시면 됩니다.

📗 Security Config 파일

가장 중요한 설정 파일입니다. 이 파일에 여러가지 보안 관련된 설정을 할 수 있습니다.

@Configuration
@EnableWebSecurity
public class SecurityConfig {

    @Bean
    public SecurityFilterChain filterChain(HttpSecurity http) throws Exception {
		
        // 인가(Authorization) 설정 코드
        http
                .authorizeHttpRequests((auth) -> auth
                        .requestMatchers("/", "/login","/loginProc","/join","joinProc", "loginFail").permitAll()
                        .requestMatchers(PathRequest.toStaticResources().atCommonLocations()).permitAll() // static 자원에 대한 접근 모두 허용
                        .requestMatchers("/api/admin/**","/admin/**").hasRole("ADMIN")
                        .requestMatchers("/logout").hasAnyRole("ADMIN", "USER")
                        .anyRequest().authenticated()
                );

        // 특정 url에 csrf 검증 비활성화
        http
                .csrf((auth) -> auth
                        .ignoringRequestMatchers("/api/**")
                );
                
		// 로그인 관련 설정
        http
                .formLogin((auth) -> auth
                        .loginPage("/login")
                        .loginProcessingUrl("/loginProc")
                        .failureForwardUrl("/loginFail")
                        .defaultSuccessUrl("/articles", true)
                        .permitAll()
                );
               
        // 로그아웃 관련 설정
        http
                .logout((auth) -> auth
                        .logoutUrl("/logout")
                        .logoutSuccessUrl("/")
                );

        // 중복 로그인 처리
        http
                .sessionManagement((auth) -> auth
                        .maximumSessions(1)
                        .maxSessionsPreventsLogin(true)
                );

        // 세션 고정 공격을 방어하기 위한 방법 - 공격자의 세션 id로 로그인 해도 새로운 세션 id가 발급되어
        // 공격자의 세션 id는 여전히 익명 사용자 세션이 됨
        http
                .sessionManagement((auth) -> auth
                        .sessionFixation().changeSessionId());

        return http.build();
    }

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

뭔가 많아보이지만 하나씩 살펴보면 간단합니다.

어노테이션 설정

먼저 @Configuration@EnableWebSecurity를 클래스 상단에 추가해주세요. 스프링 시큐리티 설정을 위한 어노테이션입니다.

인가(Authorization) 설정

http
        .authorizeHttpRequests((auth) -> auth
                .requestMatchers("/", "/login","/loginProc","/join","joinProc", "loginFail").permitAll()
                .requestMatchers(PathRequest.toStaticResources().atCommonLocations()).permitAll() // static 자원에 대한 접근 모두 허용
                .requestMatchers("/api/admin/**","/admin/**").hasRole("ADMIN")
                .requestMatchers("/logout").hasAnyRole("ADMIN", "USER")
                .anyRequest().authenticated()
        );

인가 관련 코드를 작성하는 곳입니다.

requestMatchers에 특정 url을 입력하면 해당 url에 대한 접근 권한을 설정할 수 있습니다.

자세히 보시면 requestMatchers 뒤에 permitAll(), hasRole(), hasAnyRole()이 붙어있는 것을 볼 수 있습니다.

  • permitAll(): 인증받지 않은 유저(로그인 하지 않은 유저)에게도 접근을 허용
  • hasRole(): 특정 권한을 갖고 있는 유저에게만 접근을 허용
  • hasAnyRole(): 인수에 전달한 여러 권한 중 하나라도 가진 유저에게 접근을 허용

예를 들어

.requestMatchers("/api/admin/**","/admin/**").hasRole("ADMIN")

이 코드는 /api/admin/**,/admin/** url 관련 요청은 ADMIN 권한을 가진 유저만 접근이 가능하다는 것을 의미합니다.

.anyRequest().authenticated()

마지막에 작성된 이 코드는 위에 설정한 url 이외에 다른 모든 url에 대한 요청은 인증된 유저에게 접근을 허용한다는 뜻입니다.

csrf 설정

// 특정 url에 csrf 검증 비활성화
http
        .csrf((auth) -> auth
                .ignoringRequestMatchers("/api/**")
        );

이는 /api/** url에 전송되는 요청은 csrf 검증을 하지 않는다는 것을 의미합니다.

  http
          .csrf(AbstractHttpConfigurer::disable);

위와 같이 코드를 작성하여 csrf 검증을 아예 적용하지 않게 할 수 있습니다. 이는 주로 csrf 검증이 필요없는 REST API server를 만들 때 사용합니다.
혹은 csrf 설정이 복잡한 초보자 분들이 이를 비활성화 할 때 사용합니다.

로그인, 로그아웃 설정

// 로그인 관련 설정
http
        .formLogin((auth) -> auth
                .loginPage("/login") // 로그인 페이지
                .loginProcessingUrl("/loginProc") // 로그인 요청을 받는 url
                .failureForwardUrl("/loginFail") // 로그인이 실패했을 때 이동하는 url
                .defaultSuccessUrl("/articles", true) // 성공했을 때 이동하는 url
                .permitAll()
        );

// 로그아웃 관련 설정
http
        .logout((auth) -> auth
                .logoutUrl("/logout") // 로그아웃 요청을 받는 url
                .logoutSuccessUrl("/") // 로그아웃이 성공했을 때 이동하는 url
        );

로그인과 로그아웃을 어떤 url을 통해 진행할건지 설정합니다.
또한 해당 과정이 실패하거나 성공했을 때 어떤 url로 이동할지도 설정합니다.

제가 작성한 것 외에 다른 여러 옵션도 많으니 직접 찾아보시면 다양한 설정을 하실 수 있을 것입니다.

다중 로그인 설정

// 중복 로그인 처리
http
        .sessionManagement((auth) -> auth
                .maximumSessions(1) // 최대 다중 로그인 허용자 설정
                .maxSessionsPreventsLogin(true)); // 최대 허용자를 넘어선 로그인에 대해 금지하는 설정

다중 로그인은 말 그대로 최대 몇 명까지 로그인을 허용할지를 결정하는 설정입니다.

세션 고정 공격 설정

// 세션 고정 공격을 방어하기 위한 방법 - 공격자의 세션 id로 로그인 해도 새로운 세션 id가 발급되어
// 공격자의 세션 id는 여전히 익명 사용자 세션이 됨
http
        .sessionManagement((auth) -> auth
                .sessionFixation().changeSessionId());

세션 고정 공격을 막는 설정입니다. 세션 고정 공격에 대한 내용을 여기서 설명하지는 않겠습니다. 따로 검색해보시면 자세히 알 수 있습니다.

간단하게 말하면 공격자가 사용자 브라우저에 자신의 세션을 심어두고 사용자가 로그인을 하면 자신도 사용자의 인증 권한을 얻게되는 공격 방법입니다.

이를 방어하기 위해 로그인을 했을 때 자신이 갖고있던 세션 아이디를 변경시켜주는 설정입니다.

BCryptPasswordEncoder (비밀번호 암호화 객체)

스프링 시큐리티에서는 기본적으로 BCrypt 암호화 객체를 제공합니다.
이를 통해 비밀번호를 암호화할 수 있습니다.

사용자의 비밀번호를 그대로 db에 저장하기보단 암호화를 하고 저장하는 게
보안상 좋습니다.

다음과 같이 회원가입 과정에서 사용합니다.

private final BCryptPasswordEncoder bCryptPasswordEncoder;

User user = User.builder()
                .username(joinDTO.getUsername())
                .password(bCryptPasswordEncoder.encode(joinDTO.getPassword()))
                .role("ROLE_USER")
                .build();
                
userRepository.save(user);

📗 유저 엔티티

스프링 시큐리티가 유저 정보를 가져오기 위해 유저 엔티티에 추가설정을 해야합니다.

아마 기존에 유저 엔티티를 만들어 놓으셨을텐데 거기에 추가로 UserDetails 인터페이스를 구현해주시면 됩니다.

UserDetails 인터페이스를 구현함으로써 스프링 시큐리티가 유저 정보를 가져올 수 있도록 합니다.

@Getter
@Entity
@NoArgsConstructor(access = AccessLevel.PROTECTED)
public class User implements UserDetails {

    @Id
    @GeneratedValue(strategy = GenerationType.IDENTITY)
    private Long id;

    @Column(unique = true)
    private String username;

    private String password;

    private String role; //admin이나 user 역할 부여용 컬럼

    @OneToMany(mappedBy = "user")
    private List<Article> articleList;

    @Builder
    public User(String username, String password, String role) {
        this.username = username;
        this.password = password;
        this.role = role;
    }
	
    // 여기서부터 UserDetails 인터페이스 구현 메서드
    @Override
    public Collection<? extends GrantedAuthority> getAuthorities() {
        return List.of(new SimpleGrantedAuthority(role));
    }

    @Override
    public String getUsername() {
        return username;
    }

    @Override
    public String getPassword(){
        return password;
    }

    @Override
    public boolean isAccountNonExpired() {
        return true;
    }

    @Override
    public boolean isAccountNonLocked() {
        return true;
    }

    @Override
    public boolean isCredentialsNonExpired() {
        return true;
    }

    @Override
    public boolean isEnabled() {
        return true;
    }
}

구현해야하는 메서드는 7개입니다.(@Override가 붙은 메서드만 보세요)

하나씩 살펴보겠습니다.

getAuthorities 메서드

@Override
public Collection<? extends GrantedAuthority> getAuthorities() {
    return List.of(new SimpleGrantedAuthority(role));
}

Config 파일에 대해 살펴볼 때 권한에 대해 언급했습니다. "ADMIN", "USER"가 있었는데, getAuthrities 함수를 통해 스프링 시큐리티는 유저의 권한 정보를 얻습니다.

유저 정보를 반환하도록 코드를 작성합니다.

getUsername, getPassword 메서드

@Override
public String getUsername() {
    return username;
}

@Override
public String getPassword(){
    return password;
}

유저의 아이디와 비밀번호를 반환하도록 작성합니다.
사용자 인증을 하기위한 유저 정보를 가져올 때 사용됩니다.

그외 4가지 메서드

@Override
public boolean isAccountNonExpired() { // 계정이 만료되었는지 반환
    return true;
}

@Override
public boolean isAccountNonLocked() { // 계정이 잠금되었는지 반환
    return true;
}

@Override
public boolean isCredentialsNonExpired() { // 계정 비밀번호가 만료되었는지 반환
    return true;
}

@Override
public boolean isEnabled() { // 계정이 활성화되어있는지 반환
    return true;
}

만약 여러분이 계정에 대한 만료 기간이나 비밀번호 만료 기간을 설정해두셨다면 해당 기간을 검증하여 결과를 반환하는 로직을 작성하시면 됩니다.

하지만 그렇지 않고 일반적인 로그인 과정을 구현하고 싶으시다면 true를 반환하도록 합니다.

role에 관하여

회원가입을 할 때 유저의 role을 설정해야할 겁니다.

이때 그냥 "ADMIN", "USER"로 하는 게 아니라
"ROLE_ADMIN", "ROLE_USER"로 설정해서 저장해야합니다.

그렇지 않으면 오류가 나올 것입니다. 꼭 지켜주세요.

📗 CustomUserDetailsService

스프링 시큐리티에서는 로그인 요청이 오면 해당 아이디의 유저를 찾아서 비밀번호를 비교하고 검증한다고 했습니다.

이때 유저를 가져오는 일을 담당하는 서비스가 필요합니다.

UserDetailsService 인터페이스를 구현하는 클래스를 만들고
메서드 한 개만 간단하게 구현해주시면 됩니다.

@Service
@RequiredArgsConstructor
public class CustomUserDetailsService implements UserDetailsService {

    private final UserRepository userRepository;

    @Override
    public UserDetails loadUserByUsername(String username) {
        return userRepository.findByUsername(username)
                .orElseThrow(()-> new IllegalArgumentException(username));
    }
}

findByUsername 메서드

말 그대로 username을 바탕으로 유저 정보를 찾는 함수입니다.
UserRepository에서 username을 통해 유저를 반환하는 함수를 작성하시면 됩니다.

Optional<User> findByUsername(String username);

저는 UserRepository에 위의 코드를 추가하였습니다.

📗 로그인 시도해보기

이를 통해 스프링 시큐리티 세션 로그인 구현이 완료되었습니다.

직접 로그인 페이지를 만들어 form login 방식으로 usernamepassword를 담아 POST 요청을 보낸다면 로그인이 완료될 것입니다.

  • html 예시
<form id=loginForm action="/loginProc" method="post" name="loginForm">
  <label for="username">아이디</label>
  <input id="username" type="text" name="username" placeholder="id"/>
  <label for="password">비밀번호</label>
  <input id="password" type="password" name="password" placeholder="password"/>
</form>

📕 참고 자료

  • 유튜브 개발자유미 채널 : 스프링 시큐리티에 대해 이해하기 쉽게 설명해주십니다. 재생목록에서 spring security를 찾아 보시면 도움이 되실 겁니다.
profile
배우고 기록하고

0개의 댓글