해당 포스트는 유튜브 '개발자 유미' 님의 스프링 시큐리티 강의 영상을 바탕으로 작성하였습니다.
https://www.youtube.com/playlist?list=PLJkjrxxiBSFCKD9TRKDYn7IE96K2u3C3U
스프링 시큐리티
스프링 시큐리티 : Spring Security
www.youtube.com
지난 포스트 보러가기
https://76codefactory.tistory.com/70
[SpringBoot] Spring Securtiy (1)
해당 포스트는 유튜브 '개발자 유미' 님의 스프링 시큐리티 강의 영상을 바탕으로 작성하였습니다.https://www.youtube.com/playlist?list=PLJkjrxxiBSFCKD9TRKDYn7IE96K2u3C3U 스프링 시큐리티스프링 시큐리티 : Spri
76codefactory.tistory.com
이전 포스트에서 작성한 SecurityConfig 파일을 다시 한번 살펴보자
이 SecurityConfig 구성파일에 의해 /admin 경로로의 요청에 대해 SecurityFilterChain에 의해 ADMIN 권한을 보유한 사용자에 한하여 접근을 허용하게 된다.
그로 인해 admin 페이지에 접근하게 되면 아래와 같이 액세스가 거부되는 것을 확인 할 수 있다.
액세스가 거부되는 원인은 현재 요청을 한 사용자가 인증(Authentication)을 수행하지 않았고 그로 인해 사용자의 권한을 확인할 수 없어 인가(Authorization)을 수행할 수 없기 때문이다.
이 문제를 해결하기 위해 먼저 사용자가 요청을 할 때 인가 처리를 위한 인증 작업이 선행되어야 한다.
1. 커스텀 로그인
앞서 말한 인증을 위해 커스텀 로그인 설정을 작성해보자. 별도로 SecurityConfig파일을 작성하지 않았다면 스프링 시큐리티 프레임워크의 기본 설정에 의해 스프링 시큐리티가 제공하는 로그인 창으로 넘어가게 되나, SecurityConfig 파일을 작성하였다면 직접 로그인 페이지에 대해서도 설정을 해주어야 한다.
먼저 로그인 페이지로 사용할 login.html 파일을 작성한다.
templates - login.html
추가로 /login 요청시 로그인페이지로 연결해주기 위한 LoginController도 작성해준다.
LoginController.java
서버를 실행 시킨 후 /login 경로로 이동하여 해당 페이지가 잘 실행되는지 확인한다.
해당 페이지를 통해 사용자가 아이디와 비밀번호를 입력하고 login 버튼을 누르게되면, form 태그의 action에 의해 /loginProc 에 POST요청으로 사용자의 아이디와 비밀번호가 전달된다. 그러므로 /loginProc 에 대한 매핑 함수를 작성한 뒤 사용자가 입력한 아이디와 비밀번호를 통해 인증과 인가 절차를 수행하는 코드를 작성해야 한다.
이제 로그인 페이지를 만들었으니 /admin 로 요청시 인증을 수행하기위해 /login 요청으로 연결시켜주어야 한다.
이를 위해 SecurityConfig 파일의 filterChain 함수를 아래와 같이 수정한다.
.formLogin() 함수를 통해 인증이 필요할 때 /login 으로 연결시키게 되며 로그인 절차를 수행할 때 /loginProcessingUrl 에서 수행하도록 설정한다. 또한 /login 페이지로의 요청은 .permitAll()을 통해 모두 허용하도록 한다.
스프링은 기본 설정으로 CSRF 설정이 활성화되어 있다. 사용자의 로그인 요청은 POST 요청을 통해 수행되는데 CSRF 설정이 활성화 되어있다면 POST 요청시 항상 CSRF 토큰을 삽입해서 보내야만한다. 개발과정의 편의를 위해 임시로 비활성화 해두기로 한다.
서버를 다시 실행시킨 후 /admin 페이지로 접근시 /login 페이지로 리다이렉트 되는것을 확인 할 수 있다.
2. 암호화 메서드 작성
스프링 시큐리티는 회원가입된 사용자의 비밀번호가 암호화 되어있다는 것을 전제로 한다.
따라서 사용자 정보를 DB에 저장하기 전에 비밀번호를 암호화한 뒤 저장해두어야 나중에 인증 작업을 수행할 수 있다.
암호화 방법에는 여러가지 방법이 있지만, 스프링 시큐리티는 암호화를 위해 BCrypt Password Encoder를 제공하며 권장하므로 해당 클래스를 Bean으로 등록하여 어디서든 사용가능하게 설정하도록 하겠다.
SecurityConfig 파일에 아래와 같은 Bean 설정을 추가하여 BCryptPasswordEncoder를 스프링 빈으로 등록시키도록 한다.
3. 데이터베이스 연결
스프링 시큐리티를 통해 사용자가 실제로 존재하는 사용자인지 '인증(Authentication)' 하고 적절한 권한을 갖고 있는지 '인가(Authroization)'을 수행한다고 하였다.
그렇기에 사용자 정보가 실제로 어딘가에 저장되어야 할 것이다. 이를 위해 미루어두었던 데이터베이스 연결을 시행한다.
이전에 주석처리 해두었던 의존성들을 다시 활성화 한다.
implementation 'org.springframework.boot:spring-boot-starter-data-jpa'
runtimeOnly 'com.mysql:mysql-connector-j'
application.properties 파일에 아래와 같이 데이터베이스 설정 정보를 기입한다.
사전에 MySQL에서 spring-security 라는 이름의 데이터베이스를 생성하였다.
4. 회원 가입 로직 작성
이제 본격적으로 회원가입 로직을 작성해보자.
앞서 말했듯이 스프링 시큐리티는 회원정보를 통해 인증과 인가를 진행하기 때문에 회원 가입, 로그인 로직을 작성해주어야한다.
회원가입을 위한 join.html 과 JoinController를 작성해보자.
join.html
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<title>JOIN</title>
</head>
<body>
<h1>This is Join Page</h1>
<form action="/joinProc" method="post" name="joinForm">
<input type="text" name="username" placeholder="Username"/>
<input type="password" name="password" placeholder="Password"/>
<input type="submit" value="Join"/>
</form>
</body>
</html>
JoinController.java
마지막으로 회원가입 페이지에 대한 접근 설정을 추가해주어야한다. 회원가입 페이지는 모든 사용자에게 접근을 허용하는 것이 일반적이므로 SecurityConfig 의 SecurityFilterChain의 requestMatchers에 "join"으로의 경로는 모두 허용함을 설정해주어야 한다.
현재까지 작성된 SecurityConfig.java 파일은 아래와 같다.
package com.example.springsecuritystudy.config;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.security.config.annotation.web.builders.HttpSecurity;
import org.springframework.security.config.annotation.web.configuration.EnableWebSecurity;
import org.springframework.security.crypto.bcrypt.BCryptPasswordEncoder;
import org.springframework.security.web.SecurityFilterChain;
// 커스텀 인증과 인가를 위한 구성파일
@Configuration
@EnableWebSecurity // 스프링 시큐리티 활성화
public class SecurityConfig {
// 비밀번호 암호화를 위한 빈 생성
@Bean // 빈으로 등록시킴으로서 어디서든 사용할 수 있게된다.
public BCryptPasswordEncoder bCryptPasswordEncoder() {
return new BCryptPasswordEncoder();
}
// 필터 빈 생성
@Bean
public SecurityFilterChain filterChain(HttpSecurity http) throws Exception{
// HttpSecurity 빈에 아래 설정 추가
http
.authorizeHttpRequests(auth -> auth // 람다 식으로 작성 해야함
// 루트 디렉토리, login 디렉토리는 모두 허용(인증 필요x)
.requestMatchers("/", "/login","/join").permitAll()
// admin 페이지는 인증된 사용자가 ADMIN 권한을 갖고 있어야함(인가)
.requestMatchers("/admin").hasRole("ADMIN")
// my경로 이후의 모든 경로에 대해 ADMIN 혹은 USER권한 보유시 접근 가능
.requestMatchers("/my/**").hasAnyRole("ADMIN", "USER")
// 위에서 설정하지 않은 모든 요청은 인증(Authenticated)을 거쳐야 접근 가능(로그인 필요)
.anyRequest().authenticated()
)
// 로그인 설정 추가
.formLogin(auth -> auth
.loginPage("/login") // 로그인 페이지로 리다이렉트
.loginProcessingUrl("/loginProc")
.permitAll()
)
// 로그인시 POST 사용을 위해 csrf 설정 해제
// csrf 설정은 디폴트로 작동된다. => csrf가 동작되면 POST 요청을 보낼때 csrf 토큰도 보내야 요청이 수행된다.
// 하지만 개발환경에서는 토큰을 따로 보내주도록 설정하지 않았기 때문에 임시로 비 활성화 한다.
.csrf(auth -> auth.disable());
return http.build();
}
}
회원 가입을 위한 JoinDTO, UserEntity, JoinService, UserRepository도 차례로 작성해주도록 한다.
JoinDTO
사용자가 입력한 값들을 가져오기 위해 사용하는 DTO클래스, dto 패키지 하단에 작성한다.
폼을 통해 사용자 정보를 얻어오기 위해 JoinController도 아래와 같이 수정한다.
UserEntity
사용자 정보를 저장하기 위한 엔티티, entity 패키지 하단에 작성한다.
동일한 이름을 가진 회원이 생성되는 것을 방지하기 위해 unique = true 속성을 부여한다.
UserRepository
사용자 정보를 DB에 저장하기 위한 레파지토리 클래스, Spring Data JPA 라이브러리를 사용하여 인터페이스만으로 기본적인 CRUD 함수들이 자동구현된다. repository 패키지 하단에 작성한다.
동일 이름을 가진 회원이 생성되는 것을 방지하기 위한 함수로 existsByUsername(String username) 을 작성한다. 이 함수는 Spring Data JPA에 의해 구체화되며, username에 해당하는 회원이 존재할 경우 true를, 존재하지 않는 경우 false를 리턴한다.
JoinService
회원가입을 수행하기 위한 서비스 계층 클래스에 해당한다. service 패키지 하단에 작성한다.
사용자의 비밀번호를 암호화하여 저장하기 위해 BCryptPasswordEncoder를 주입받는다.
userRepository의 existsByUsername()를 호출하여 동일한 이름의 회원이 생성되는 것을 방지한다.
이후 join 페이지에 접속하여 회원가입을 진행해보면 아래와 같이 정상적으로 저장되는것을 MySQL Workbench에서 확인할 수 있다.
5. DB 기반 로그인 검증 로직
사용자가 인증(Authentication)을 위해 로그인을 시도하면 스프링 시큐리티는 UserDetailsService 라는 서비스계층에서 사용자 정보를 조회하여 검증을 수행한다. UserDetailsService는 UserDetails 객체를 반환하는데, 이는 스프링 시큐리티가 사용자 정보를 저장하고 관리하는 데 사용된다.
기본적으로 스프링 시큐리티는 User 엔티티를 사용하여 로그인 과정을 처리한다. 하지만, 사용자 정의 엔티티(이 게시글의 UserEntity.class에 해당)를 사용하여 로그인 과정을 처리하고자 할 경우, UserDetailsService를 구현한 CustomUserDetailsService를 정의해주어야 한다.
UserDetailsService 인터페이스는 loadUserByUsername(String username) 메서드를 제공한다. 이 메서드는 사용자 이름(username)을 통해 UserDetails 객체를 반환하는 역할을 한다. 따라서, 사용자 정의 엔티티를 사용하여 인증 절차를 구현하기 위해서는 CustomUserDetailsService 클래스에서 loadUserByUsername() 메서드를 오버라이딩하여, 사용자 이름을 통해 사용자 정보를 조회하고, 이를 기반으로 UserDetails 객체를 반환하도록 구현해야한다.
이제부터 이 구현과정을 차근차근 진행해보자
service 디렉토리 밑에 CustomUserDetailService 를 작성한다.
CustomUserDetailService의 이름은 무엇으로 짓든 상관 없으며, UserDetailsService를 구체화하여 스프링 시큐리티가 이 서비스 클래스를 통해 인증/인가 를 수행하도록 한다.
UserDetailsService는 loadUserByUsername 함수를 사용하여 UserDetails를 생성하여 사용자 정보를 관리하는데, CustomUserDetailsService는 이를 상속받았으므로 해당 함수를 구체화 해주어야한다.
매개변수로 전달받은 username(사용자 이름)을 바탕으로 우리가 정의한 커스텀 엔티티(UserEntity)를 정의한다음, 해당 엔티티로부터 정보를 추출하여 UserDetails를 생성하여 반환해주면 앞서 정의한 SecurityConfig로 전달되어 비로서 사용자의 인증/인가가 진행되는 것이다.
UserDetails를 생성하여 반환하기 위해 CustomUserDetails라는 객체를 정의하도록 한다. 해당 객체는 UserDetails 인터페이스를 구체화하는 것으로 여러가지 함수를 구체화해주어야 한다. 또한 CustomUserDetailsService로부터 엔티티 정보를 전달받기 위해 UserEntity를 매개변수로 전달받는 생성자를 추가로 작성해주었다.
필수적으로 구현해주어야 하는 함수들은 아래와 같다.
1. getAuthorities() : 사용자가 보유한 권한들을 Collection<GrantedAuthority> 형태로 반환한다. 엔티티가 보유중인 권한들을 바탕으로 GrantedAuthority()를 생성한 뒤 컬렉션에 삽입하고 반환한다.
2. getPassword() : 사용자 비밀번호를 반환한다.
3. getUsername() : 사용자의 Username을 반환한다.
public class CustomUserDetails implements UserDetails {
private UserEntity userEntity;
public CustomUserDetails(UserEntity userEntity) {
this.userEntity = userEntity;
}
// 사용자의 권한을 반환한다. // 데이터베이스에 저장한 ROLE에 해당한다.
@Override
public Collection<? extends GrantedAuthority> getAuthorities() {
Collection<GrantedAuthority> collection = new ArrayList<>();
collection.add(new GrantedAuthority() {
@Override
public String getAuthority() {
return userEntity.getRole(); // 사용자의 권한 리턴
}
});
return collection;
}
// 사용자 비밀번호를 반환한다.
@Override
public String getPassword() {
return userEntity.getPassword();
}
// 사용자 username을 반환한다.
@Override
public String getUsername() {
return userEntity.getUsername();
}
}
그럼 이제 테스트를 진행해보자.
SecurityConfig 코드를 다시 살펴보면 /admin 페이지로의 접근에 대해 ADMIN 권한을 보유해야 접근이 가능한것으로 되어있다.
정상적으로 동작하는지 확인하기 위해 ADMIN 권한을 보유한 새 유저를 생성하고 해당 유저로 admin 페이지로의 접근을 시도해보겠다.
JoinService의 joinProcess() 함수에서 role을 "ROLE_ADMIN"으로 수정한뒤, 회원 생성을 진행한다.
회원가입 페이지에 접근한 뒤 admin을 username으로 하여 회원가입을 진행한다.
확인결과 ROLE_ADMIN 권한을 가진 사용자가 생성되었다.
이제 admin 페이지로 접근해보자. 그럼 당연하게도 사용자 권한을 확인하기 위해 /login 페이지로 리다이렉트 된다.
앞서 회원가입한 ADMIN권한을 가진 계정으로 로그인하여 접근이 되는지 확인해보면 성공적으로 접근되는것을 확인할 수 있다.
만약 로그인에 성공하였지만 권한이 달라 접근할 수 없다면 아래와 같이 white label이 보여지는 것을 알 수 있다.
6. 세션 사용자 아이디 정보
SecurityConfig에서 정의한 Spring Security Filter Chain에 의해 사용자의 로그인이 이루어지면, 스프링 시큐리티는 사용자 정보(UserDetails)를 세션에 저장한다.
구체적으로 설명하면 아래와 같다.
- 로그인 과정:
- 사용자가 로그인 폼을 제출하면, 스프링 시큐리티는 UsernamePasswordAuthenticationFilter를 사용하여 인증 요청을 처리한다.
- 이 필터는 사용자가 입력한 사용자 이름과 비밀번호를 AuthenticationManager를 통해 인증한다.
- AuthenticationManager는 여러 AuthenticationProvider를 사용하여 실제 인증을 수행하며, 이 과정에서 UserDetailsService를 통해 사용자 정보를 조회한다.
- UserDetails 저장:
- 인증이 성공하면, 스프링 시큐리티는 인증된 사용자 정보를 나타내는 Authentication 객체를 생성한다. 이 객체는 UserDetails를 포함한다.
- 생성된 Authentication 객체는 SecurityContextHolder를 통해 SecurityContext에 저장된다. 기본적으로 SecurityContext는 세션에 저장되므로, 사용자 정보(UserDetails)도 세션에 저장된다.
- 세션 관리:
- 세션에 저장된 SecurityContext는 사용자가 애플리케이션 내에서 이동할 때마다 인증 상태를 유지하는 데 사용된다. 이를 통해 사용자는 로그인 상태를 유지하고, 권한 검사를 받을 수 있게된다.
네이버, 구글, 등의 사이트에서 로그인에 성공하게되면 권한이 필요한 다른 요청들에 대해서 여러번 로그인을 할 필요없이 사용자 정보가 유지되는 것 또한 이러한 원리를 바탕으로 한다.
깊은 이해를 위해 앞서 작성한 메인페이지를 수정해보자.
스프링 시큐리티를 통해 저장된 사용자 정보는 SecurityContextHolder를 통해서 얻을 수 있다.
아래와 같이 코드를 작성한뒤 model에 데이터를 담고 html 페이지에서 값을 확인해보자.
main.html
서버를 실행하고 메인페이지로 접속하면 아직 로그인을 하지 않았기 때문에 anonymouseUser라고 출력된다.
이후 로그인을 하고 다시 메인페이지에 접속하게 되면 아래와 같이 현재 로그인한 사용자의 정보와 권한 정보를 확인할 수 있다.
이후 새로고침을 아무리 진행하더라도 사용자의 정보는 SecurityContext에 담겨져있기 때문에 사용자 정보가 유지되는것을 확인 할 수 있다.
7. 세션 설정
사용자가 로그인을 진행하면 사용자 정보는 SecurityContextHolder라는 서버 세션에 저장되고, 사용자에게는 이 세션에 대한 세션 ID를 반환하여 프론트 단에서 쿠키로 관리하게 된다. 이후 프론트 엔드에서 서버로 요청을 보낼 때 마다 이 세션 ID를 함께 보내게 되면 서버는 사용자가 현재 로그인한 사용자임을 알 수 있으며 필요할 때 사용자에 대한 정보를 얻어올 수 있게 되는 것이다.
하지만 이러한 세션 기반의 사용자 관리 방식은 접속한 사용자의 수가 많아지면 많아질 수록 서버 세션에 현재 로그인한 사용자를 저장하고 관리해야하므로 서버의 성능이 하락될 수 있다. 이를 방지 하기위해 사용자 정보를 서버 세션에 저장하지 않는, Stateless한 방식으로 JWT를 활용하는 방식이 있으나 이에 대해서는 나중에 포스트 하도록 하고 먼저 세션을 효율적으로 관리하기 위한 설정들에 대해 알아보겠다.
1. 세션 소멸 시간 설정
세션 타임아웃 설정을 통해 로그인 이후 세션이 유지되고 소멸되는 시간을 설정 할 수 있다.
세션 소멸 시점은 서버에 마지막 특정 요청을 수행한 뒤 설정한 시간이 지나면 세션이 소멸되게 된다. 스프링에서 제공하는 기본 설정은 1800초(30분)이므로, 서버 환경에 알맞게 적절한 값으로 설정 해야한다.
설정은 application.properties에 아래와 같이 작성함으로서 가능하다.
2. 다중 로그인 설정
하나의 아이디에 대해서 세션을 몇개까지 생성가능한지 설정할 수 있다.
SecurityConfig에서 설정이 가능하다.
@Bean
public SecurityFilterChain securityFilterChain(HttpSecurity http) throws Exception{
http
.sessionManagement((auth) -> auth
.maximumSessions(1)
.maxSessionsPreventsLogin(true));
return http.build();
}
maximumSession(Integer) : 하나의 아이디에 대한 다중 로그인 허용 개수를 설정한다.
maxSessionPreventsLogin(Boolean) : 다중 로그인 개수를 초과하였을 경우 처리 방법을 정의한다.
- true : 초과시 새로운 로그인 차단
- false : 초과시 기존 세션 하나 삭제
3. 고정 보호
세션 고정 공격을 보호하기 위한 로그인 성공시 세션 설정 방법은 sessionManagement() 메소드의 sessionFixation() 메소드를 통해서 설정할 수 있다.
- sessionManagement().sessionFixation().none() : 로그인 시 세션 정보 변경 안함
- sessionManagement().sessionFixation().newSession() : 로그인 시 세션 새로 생성
- sessionManagement().sessionFixation().changeSessionId() : 로그인 시 동일한 세션에 대한 id 변경
'Backend' 카테고리의 다른 글
[SpringBoot] Spring Securtiy JWT(2) (1) | 2024.06.22 |
---|---|
[SpringBoot] Spring Securtiy JWT(1) (0) | 2024.06.18 |
[SpringBoot] Spring Securtiy (1) (0) | 2024.06.11 |
[Spring] Spring Initializr Naming Rule (0) | 2024.06.08 |
[SpringBoot] SSL 인증서 발급하고 적용하기 (1) | 2024.02.22 |