스프링 시큐리티를 적용하며 복잡하게 느껴졌던 세션 로그인 구현 과정을 정리해보고자 글을 작성하게 되었습니다. 다음은 제가 프로젝트에 적용했던 버전 정보입니다.
Spring Boot : 3.3.5 버전
Java : 17 버전
인증&인가를 편하게 구현할 수 있도록 스프링에서 제공해주는 보안 관련 프레임워크입니다.
인증과 인가라는 단어를 자주 들어보셨을테지만 헷갈리는 개념이라 짚고 넘어가겠습니다.
인증(Authentication) : 사용자가 누구인지 검증하는 과정
인가(Authorization) : 사용자의 역할에 따라 권한을 부여하는 과정
서버를 회사라고 비유해보겠습니다.
회사 출입 카드를 사용하여 회사에 들어가는 과정 = 인증
직급에 따라 출입가능한 사무실이 다르게 배치되는 것 = 인가
이렇게 생각하면 이해하기 쉬울 겁니다.
전체적인 인증 과정을 살펴보겠습니다.
스프링 시큐리티의 인증 내부 구조입니다. 간단한 흐름은 다음과 같습니다.
내부구조를 보면 엄청 복잡해보이지만 사실 구현할게 그리 많지 않습니다.
dependencies {
implementation 'org.springframework.boot:spring-boot-starter-security'
}
spring-boot-starter-security
의존성 하나만 의존성 정보에 추가해주시면 됩니다.
가장 중요한 설정 파일입니다. 이 파일에 여러가지 보안 관련된 설정을 할 수 있습니다.
@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
를 클래스 상단에 추가해주세요. 스프링 시큐리티 설정을 위한 어노테이션입니다.
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()
이 붙어있는 것을 볼 수 있습니다.
예를 들어
.requestMatchers("/api/admin/**","/admin/**").hasRole("ADMIN")
이 코드는 /api/admin/**
,/admin/**
url 관련 요청은 ADMIN 권한을 가진 유저만 접근이 가능하다는 것을 의미합니다.
.anyRequest().authenticated()
마지막에 작성된 이 코드는 위에 설정한 url 이외에 다른 모든 url에 대한 요청은 인증된 유저에게 접근을 허용한다는 뜻입니다.
// 특정 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());
세션 고정 공격을 막는 설정입니다. 세션 고정 공격에 대한 내용을 여기서 설명하지는 않겠습니다. 따로 검색해보시면 자세히 알 수 있습니다.
간단하게 말하면 공격자가 사용자 브라우저에 자신의 세션을 심어두고 사용자가 로그인을 하면 자신도 사용자의 인증 권한을 얻게되는 공격 방법입니다.
이를 방어하기 위해 로그인을 했을 때 자신이 갖고있던 세션 아이디를 변경시켜주는 설정입니다.
스프링 시큐리티에서는 기본적으로 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가 붙은 메서드만 보세요)
하나씩 살펴보겠습니다.
@Override
public Collection<? extends GrantedAuthority> getAuthorities() {
return List.of(new SimpleGrantedAuthority(role));
}
Config 파일에 대해 살펴볼 때 권한에 대해 언급했습니다. "ADMIN"
, "USER"
가 있었는데, getAuthrities
함수를 통해 스프링 시큐리티는 유저의 권한 정보를 얻습니다.
유저 정보를 반환하도록 코드를 작성합니다.
@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;
}
만약 여러분이 계정에 대한 만료 기간이나 비밀번호 만료 기간을 설정해두셨다면 해당 기간을 검증하여 결과를 반환하는 로직을 작성하시면 됩니다.
하지만 그렇지 않고 일반적인 로그인 과정을 구현하고 싶으시다면 true
를 반환하도록 합니다.
회원가입을 할 때 유저의 role을 설정해야할 겁니다.
이때 그냥 "ADMIN"
, "USER"
로 하는 게 아니라
"ROLE_ADMIN"
, "ROLE_USER"
로 설정해서 저장해야합니다.
그렇지 않으면 오류가 나올 것입니다. 꼭 지켜주세요.
스프링 시큐리티에서는 로그인 요청이 오면 해당 아이디의 유저를 찾아서 비밀번호를 비교하고 검증한다고 했습니다.
이때 유저를 가져오는 일을 담당하는 서비스가 필요합니다.
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));
}
}
말 그대로 username을 바탕으로 유저 정보를 찾는 함수입니다.
UserRepository
에서 username
을 통해 유저를 반환하는 함수를 작성하시면 됩니다.
Optional<User> findByUsername(String username);
저는 UserRepository에 위의 코드를 추가하였습니다.
이를 통해 스프링 시큐리티 세션 로그인 구현이 완료되었습니다.
직접 로그인 페이지를 만들어 form login
방식으로 username
과password
를 담아 POST
요청을 보낸다면 로그인이 완료될 것입니다.
<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>