프롬프트 엔지니어링 사이트를 제작하는 프로젝트에서, 필자는 교육용 페이지와 댓글, 마이페이지를 만드는 작업을 수행했다. login/register 기능은 다른 백엔드 팀원이 작업했다.
하지만... 스프링 세큐리티에 대한 지식이 없던 나는 팀원이 작성한 코드를 전혀 이해하지 못했다.
적어도 본인이 한 프로젝트의 기능들은 모두 이해해야 한다고 생각하기 때문에, Spring Security를 따로 공부하면서 코드를 하나씩 이해해보려 한다.
⚠️ 코드를 이해하려면 Spring과 JPA 지식이 필요합니다!
Spring Security
: 애플리케이션에서 인증과 권한 부여를 관리하는 프레임워크
Spring Security를 사용하지 않으면?
➜
DispatcherServlet
이 HTTP 요청을 받아서 각 컨트롤러에 전달한다.
Spring Security를 사용하면?
➜
DispatcherServlet
으로 전달되기 전에, Spring Security가 모든 요청을 intercept한다.
자세한 동작 원리는 다음과 같다.
HTTP 요청이 들어온다. (로그인)
UsernamePasswordAuthenticationFilter
가 사용자의 이름과 비밀번호를 캡쳐하고, AuthenticationManager
에 넘긴다.
AuthenticationManager
는 해당 인증을 처리할 수 있는 AuthenticationProvider
에게 넘긴다.
AuthenticationProvider
가 UserDetailsService
를 통해 사용자 정보를 로드하고 검증한다.
인증 성공시 Authentication
객체가 SecurityContextHolder
에 저장된다.
AuthenticationProvider
는UserDetailsService
를 통해 사용자 정보를 로드하고 검증한다.
UserDetailService
는 DB에서 사용자 정보를 로드한다.UserDetails
타입으로 리턴된다.그렇다면, UserDetailService
는 어떻게 사용자 정보를 로드하는 걸까?
➜ UserDetailService
인터페이스를 구현한 구현객체는 UserDetails
타입을 반환하는 메소드를 오버라이딩한다.
➜ UserDetailService
는 해당 메소드를 호출해서 DB에 있는 사용자 정보를 UserDetails
타입으로 반환한다.
build.gradle
이나 DB 연결 설정이 완료되어 있다는 가정 하에 Spring Security를 사용하는 방법을 알아보자.
우선, 간단한 컨트롤러를 하나 만들어보자.
@Controller
public class SimpleController {
@GetMapping("/")
public @ResponseBody String index() {
return "인덱스 페이지";
}
@GetMapping("/user")
public @ResponseBody String user()
return "유저 페이지";
}
@GetMapping("/admin")
public @ResponseBody String admin() {
return "어드민 페이지";
}
@GetMapping("/manager")
public @ResponseBody String manager() {
return "매니저 페이지";
}
@GetMapping("/loginForm")
public String login() {
return "loginForm";
}
@GetMapping("/register")
public String register() {
return "register";
}
}
현재의 컨트롤러는 아무런 기능이 없다.
그냥 각 페이지에 대한 설명을 return할 뿐이다.
여기에 Spring Security를 적용해보자!
우선, Config 파일을 하나 만들어줘야 한다.
@Configuration // 이 클래스가 스프링 설정 클래스임을 나타냅니다.
@EnableWebSecurity // 필터체인에 등록
public class SecurityConfig {
@Bean
public BCryptPasswordEncoder encodePwd() {
return new BCryptPasswordEncoder();
}
@Bean
public SecurityFilterChain filterChain(HttpSecurity http) throws Exception {
http.csrf().disable();
// CSRF(Cross-Site Request Forgery) 보호를 비활성화합니다.
http.authorizeRequests()
.antMatchers("/user/**").authenticated()
// 위 경로의 모든 요청은 인증된 사용자만 접근할 수 있습니다.
.antMatchers("/admin/**").access("hasRole('ROLE_ADMIN') or hasRole('ROLE_USER')")
// 위 경로의 요청은 ROLE_ADMIN 또는 ROLE_USER 권한을 가진 사용자만 접근할 수 있습니다.
.antMatchers("/admin/**").access("hasRole('ROLE_ADMIN')")
// ROLE_ADMIN 권한을 가진 사용자만 위 경로에 접근할 수 있습니다.
.anyRequest().permitAll()
/* 나머지 모든 요청은 인증 없이 접근할 수 있습니다. */
.and()
.formLogin()
.loginPage("/loginForm")
/* 로그인 페이지의 URL을 설정. 사용자가 로그인하지 않은 상태에서 인증이 필요한 페이지에 접근하면 이 페이지로 리다이렉트된다. */
return http.build();
}
}
위의 설정을 통해 Spring Security는 지정된 URL 패턴에 따라 접근 권한을 관리할 수 있게 되었다.
간단한 회원가입 페이지를 만들어보자.
<!DOCTYPE html>
<html>
<head>
<meta charset="UTF-8">
<title>회원가입 페이지</title>
</head>
<body>
<h1>회원가입 페이지</h1>
<hr/>
<form action="/register" method="post">
<input type="text" name="username" placeholder="Username"/> <br/>
<input type="password" name="password" placeholder="Password"/> <br/>
<input type="email" name="email" placeholder="Email"/> <br/>
<button>회원가입</button>
</form>
</body>
</html>
@GetMapping("registerForm")
public String registerForm(){
return "registerForm";
}
위의 폼에 내용을 입력한 뒤, 버튼을 누르면 /register
URL로 POST 요청을 보낸다.
컨트롤러는 해당 요청을 받아서 회원가입을 진행해야 한다.
이때, 한 가지 주의해야 할 사항이 있다.
⚠️ 비밀번호를 DB에 그대로 저장하면 안된다!!!
비밀번호가 암호화되지 않으면, 시큐리티로 로그인을 할 수 없다.
∴ Config 파일에서 빈으로 등록한 BCryptPasswordEncoder
을 사용해서 비밀번호를 암호화 해야 한다.
회원가입을 위해 사용자 정보를 DB에 저장하는 코드는 다음과 같다.
@PostMapping("/register")
// user는 폼에 입력한 정보를 통해 바인딩
public String register(User user) {
String rawPassword = user.getPassword();
String encPassword = bCryptPasswordEncoder.encode(rawPassword); // 비밀번호 암호화
user.setPassword(encPassword);
user.setRole("ROLE_USER");
userRepository.save(user);
return "redirect:/";
}
앞서 설명했듯이, Spring Security는 UserDetailService
를 통해 DB에서 사용자 정보를 로드한다.
UserDetailService
는 인터페이스이기 때문에, 이를 구현한 구현객체를 만들어줘야 한다.
또한, UserDetails
인터페이스를 구현하여 사용자 정보를 캡슐화해야 한다.
public class PrincipalDetails implements UserDetails {
private User user;
public PrincipalDetails(User user) {
this.user = user;
}
@Override
public Collection<? extends GrantedAuthority> getAuthorities() {
return Collections.singleton(new SimpleGrantedAuthority(user.getRole()));
}
@Override
public String getPassword() {
return user.getPassword();
}
@Override
public String getUsername() {
return user.getUsername();
}
@Override
public boolean isAccountNonExpired() {
return true;
}
@Override
public boolean isAccountNonLocked() {
return true;
}
@Override
public boolean isCredentialsNonExpired() {
return true;
}
@Override
public boolean isEnabled() {
return true;
}
}
@Service
public class PrincipalDetailsService implements UserDetailsService {
@Autowired
private UserRepository userRepository;
@Override
public UserDetails loadUserByUsername(String username) throws UsernameNotFoundException {
User user = userRepository.findByUsername(username)
.orElseThrow(() -> new UsernameNotFoundException("User not found"));
return new PrincipalDetails(user);
}
}
이제 사용자 검증을 수행할 비즈니스 로직들은 다 작성했다.
그렇다면, 로그인을 처리하기 위한 컨트롤러는 어떻게 구현해야 할까?
컨트롤러는 구현할 필요가 없다!
Spring Security에서 자동적으로 로그인을 처리하도록 만들어주면 된다.
Config 파일의 http.authorizeRequests()
에 다음과 같은 코드를 추가하면 된다.
.loginProcessingUrl("/login") // 이 URL로 요청이 오면 시큐리티가 로그인을 처리해준다.
.defaultSuccessUrl("/") // login 이후 리다이렉트 될 위치
Config 파일에 @EnableGlobalMethodSecurity(securityEnabled = true)
어노테이션을 추가하면, 간편한 권한 처리가 가능하다.
예를 들어, /info
URL로 들어오는 요청을 제한하고 싶다면, 아래와 같은 어노테이션을 추가해주기만 하면 된다.
@Secured("ROLE_ADMIN")
@GetMapping("/info")
public @ResponseBody String info() {
return "info";
}
이제, /info
URL로 접속하려면 ADMIN 권한이 필요하다.
다른 방법으로는 다음과 같은 어노테이션을 사용할 수도 있다.
@PreAuthorize("hasRole(ROLE_ADMIN)")
@GetMapping("/info")
public @ResponseBody String info() {
return "info";
}