[작성일: 2023. 09. 16]
Security + JWT로 로그인을 구현한 경험이 있지만 동작되는 원리를 잘 이해하지 못해서 강의를 듣고 블로그에 정리하면서 공부하려고 한다.
나중에 다시봐도 이해할 수 있게, 또는 Security에 대해 모르는 누가 봐도 이해할 수 있게 작성하는 것이 목표다.
환경설정
강의가 나랑 다른 환경에서 진행되고 있기 때문에 무작정 따라하지 않고 나는 내 환경에 맞춰서 공부를 하려고 한다.
application.yml
thymeleaf로 바꾸면서 필요없을 거 같은 설정은 우선 다 제외했다.
그리고 mysql로 되어있던 datasource 부분도 mariadb로 바꿔주었다.
spring:
datasource:
driver-class-name: org.mariadb.jdbc.Driver
url: jdbc:mariadb://localhost:3306/security?serverTimezone=Asia/Seoul
username: j
password: 1234
jpa:
hibernate:
ddl-auto: create
naming:
physical-strategy: org.hibernate.boot.model.naming.PhysicalNamingStrategyStandardImpl
show-sql: true
강의에서는 ViewResolver에 대한 기본 설정을 위해 WebMvcConfig 파일도 작성한다.
Spring Boot에서는 Thymeleaf와 관련된 모든 기본 설정이 이미 내장되어 있으며 특별한 요구사항이 없다면 따로 설정은 필요하지 않다고 한다.
특히 프로젝트를 만들 때 Thymeleaf 의존성을 이미 추가했기 때문에 ViewResolver를 설정할 필요가 없다.
그래서 난 해당 파일은 작성하지 않고 넘어갔다.
Security 설정
config 파일 작성 전 인덱스 페이지와 controller를 먼저 간단하게 작성한다.
@Controller
public class IndexController {
@GetMapping({"", "/"})
public String index() {
return "index";
}
@GetMapping("/user")
public @ResponseBody String user() {
return "user";
}
@GetMapping("/admin")
public @ResponseBody String admin() {
return "admin";
}
@GetMapping("/manager")
public @ResponseBody String manager() {
return "manager";
}
// 스프링 시큐리티가 해당 주소를 낚아챔. -> SecurityConfig 파일 생성 후 낚아채지 않음.
@GetMapping("/login")
public @ResponseBody String login() {
return "login";
}
@GetMapping("/join")
public @ResponseBody String join() {
return "join";
}
@GetMapping("/joinProc")
public @ResponseBody String joinProc() {
return "회원가입이 완료되었습니다.";
}
}
컨트롤러를 처음 작성하고나면 어느 페이지를 가도 login 페이지를 가게 되는데, 아이디는 user, 패스워드는 인텔리제이 콘솔창에 나와있는 패스워드를 입력하면 인덱스 페이지로 넘어간다.
하지만 manager 페이지는 manager 권한이 있는 사람만, admin 페이지는 admin 권한이 있는 사람만 들어가게 하고 싶다.
그 권한 설정을 위해 Security Config 파일을 작성한다.
참고로 강의에 나오는 WebSecurityConfigurerAdapter는 deprecated 되었다.
현재는 SecurityFilterChain을 Bean으로 등록하면 된다고 한다.
또한 andMatchers는 아예 삭제가 되어버렸기 때문에 requestMatchers를 사용한다.
package com.cos.security1.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.web.SecurityFilterChain;
@Configuration
@EnableWebSecurity // 스프링 시큐리티 필터가 스프링 필터체인에 등록이 됨.
public class SecurityConfig {
@Bean
public SecurityFilterChain filterChain(HttpSecurity http) throws Exception{
http.csrf().disable();
http.authorizeHttpRequests()
.requestMatchers("/user/**").authenticated()
.requestMatchers("/manager/**").hasAnyRole("MANAGER", "ADMIN")
.requestMatchers("/admin/**").hasAnyRole("ADMIN")
.anyRequest().permitAll()
.and()
.formLogin()
.loginPage("/login"); // 로그인 페이지로 이동
return http.build();
}
/*
지금은 WebSecurityConfigurerAdapter가 deprecated 되어 사용할 수 없다.
@Override
protected void configure(HttpSecurity http) throws Exception {
http.csrf().disable();
http.authorizeRequests()
.andMatchers("/user/**").authenticated()
.andMatchers("/manager/**").access("hasRole('ROLE_ADMIN) or hasRole('ROLE_MANAGER')")
.antMatchers("/admin/**").access("hasRole('ROLE_ADMIN')")
.anyRequest().permitAll()
.and
.formLogin()
.loginPage("/login") // 로그인 페이지로 이동
}
*/
}
- http.csrf().disable()
- csrf(Cross-Site Request Forgery) 보호를 비활성화 한다는 의미이다.
- http.authorizeHttpRequests()
- HTTP 요청에 대한 접근 제어 설정을 시작한다는 의미이다.
- requestMatchers("/user/**").authenticated()
- /user/로 시작하는 모든 URL은 인증된 사용자만 접근할 수 있다.(로그인)
- hasAnyRole("MANAGER", "ADMIN")
- requestMatchers에 설정된 URL은 MANAGER, ADMIN 권한을 가진 사용자만 접근할 수 있다.
- anyRequest().permitAll()
- 이 설정은 모든 요청에 대해 접근을 허용하겠다는 의미이다. 즉, 특별한 보안 제약 없이 어떤 사용자든 해당 웹 애플리케이션의 모든 경로에 접근할 수 있게 된다.
- formLogin()
- 폼 기반의 로그인을 활성화한다.
- Spring Security는 폼을 통한 로그인 처리를 위한 여러 기본 설정을 제공한다.
- loginPage("/loginForm")
- 인증이 필요한 페이지에 접근하려고 할 때 인증되지 않은 사용자를 리다이렉트 시킬 로그인 페이지의 URL을 의미한다.
- 이 설정을 사용하지 않으면 Spring Security는 기본 로그인 폼을 제공한다.
여기까지 설정하고 나서 프로젝트 실행 후 각 페이지에 가보면 로그인 페이지로 이동하게 된다.
회원가입
로그인 컨트롤러를 loginForm으로 이동하도록 코드를 수정하고 loginForm 페이지를 간단하게 만든다.
그 다음 데이터베이스 테이블을 생성하기 위해 User entity 클래스를 생성한다.
@Entity
@Getter
@Setter
public class User {
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
private String id;
private String username;
private String password;
private String email;
private String role; // USER, ADMIN
@CreationTimestamp
private Timestamp createDate;
}
그 다음 회원가입을 위한 joinForm 페이지도 간단하게 만들어주고, userRepository를 생성한다.
@Repository라는 애노테이션이 없어도 JpaRepository를 상속했기 때문에 IoC가 가능하다.
(난 습관 들이려고 그냥 작성했다.)
// 기본적인 CRUD 함수를 JpaRepository가 들고 있음.
@Repository
public interface UserRepository extends JpaRepository<User, Long> {
}
컨트롤러에서 joinForm 부분도 변경해준다.
방금 만든 Repository를 주입받고, GetMapping으로 되어있던 부분을 PostMapping으로 바꿔준다.
ROLE을 입력받는 부분이 아직 없기 때문에 setter를 사용해서 넣어준다.
@Controller
@RequiredArgsConstructor
public class IndexController {
private final UserRepository userRepository;
@PostMapping("/join")
public @ResponseBody String join(User user) {
System.out.println(user);
user.setRole("USER");
userRepository.save(user);
return "join";
}
}
하지만 이런 식으로 코드를 작성하면 비밀번호가 저장되기 때문에 시큐리티로 로그인 할 수 없다.
바로 패스워드가 암호화되지 않았기 때문이다.
우선 이것을 해결하기 위해 SecurityConfig 파일에서 패스워드 암호화를 Bean으로 등록해줘야 한다.
@Configuration
@EnableWebSecurity // 스프링 시큐리티 필터가 스프링 필터체인에 등록이 됨.
public class SecurityConfig {
@Bean // 해당 메서드의 리턴되는 오브젝트를 IoC로 등록해줌.
public BCryptPasswordEncoder encodePwd() {
return new BCryptPasswordEncoder();
}
// ...코드 생략
}
@Controller
@RequiredArgsConstructor
public class IndexController {
private final UserRepository userRepository;
private final BCryptPasswordEncoder bCryptPasswordEncoder;
// ...코드 생략
@PostMapping("/join")
public String join(User user) {
System.out.println(user);
user.setRole("USER");
String rawPassword = user.getPassword();
String encPassword = bCryptPasswordEncoder.encode(rawPassword);
user.setPassword(encPassword);
userRepository.save(user);
return "redirect:/loginForm";
}
}
등록한 Bean을 주입받고, 패스워드에 인코딩 된 패스워드를 넣어준다.
그 후 회원가입을 해보면 DB에는 인코딩 된 패스워드가 저장되는 것을 확인할 수 있다.
로그인
우선 SecurityConfig 파일의 filterChain 코드 아래에 두 줄을 추가한다.
.loginProcessingUrl("/login")
.defaultSuccessUrl("/");
- loginProcessingUrl
- POST 요청이 /login URL로 들어올 때 Spring Security가 해당 요청을 인터셉트해서 사용자의 로그인 처리를 담당하게 하는 것을 의미한다.
- 일반적으로 로그인 폼에서 아이디와 패스워드를 입력한 후 로그인 버튼을 클릭하면 해당 정보와 함께 POST 요청이 발생한다. 이 설정을 사용함으로써 Spring Security는 이 요청을 감지하고 사용자 인증 처리를 직접 한다.
- defaultSuccessUrl
- 로그인에 성공한 후 사용자를 리다이렉트 시킬 기본 URL을 의미한다.
그 다음은 config 패키지에 auth 패키지를 생성하고 PrincipalDetails 클래스를 생성한다.
public class PrincipalDetails implements UserDetails {
private User user; // composition
public PrincipalDetails(User user) {
this.user = user;
}
// 해당 유저의 권한을 리턴하는 곳
@Override
public Collection<? extends GrantedAuthority> getAuthorities() {
Collection<GrantedAuthority> collect = new ArrayList<>();
collect.add(new GrantedAuthority() {
@Override
public String getAuthority() {
return user.getRole();
}
});
return collect;
}
@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;
}
}
Security는 /login 주소 요청이 오면 인터셉트 해서 로그인을 진행시킨다.
로그인 진행이 완료가 되면 Security ContextHolder에 Security session을 만들어준다. 같은 session 공간이지만 security가 자신만의 session 공간에 만들어준다.
이때 session에 들어갈 수 있는 오브젝트는 Authentication 타입의 객체로 정해져있다.
그리고 Authentication 안에는 User 정보가 있어야 되는데 이 때 User 오브젝트의 타입도 UserDetails 타입의 객체로 정해져있다.
다시 정리하면,
security session 영역이 있고 여기에 세션 정보를 저장하고 여기 들어갈 수 있는 객체는 Authentication,
Authentication에 유저 정보를 저장할 때는 UserDetails 를 사용한다.
아래에 계정 여부 관련한 코드에서는 언제 false로 바꿔주면 될까?
예를 들면 우리 사이트에서 1년 동안 회원이 로그인하지 않았을 경우 휴면 계정으로 변경하기로 했을 때,
(현재 시간 - 로그인 시간) 계산을 통해 1년이 지나면 return을 false로 설정할 수 있다.
그 다음, PrincipalDetailsService 클래스를 생성한다.
이 클래스는 security 설정에서 loginProcessingUrl("/login")으로 해놨기 때문에 /login 요청이 오면 자동으로 UserDetailsService 타입으로 IoC 되어있는 loadUserByUsername 함수가 실행된다.
@Service
@RequiredArgsConstructor
public class PrincipalDetailsService implements UserDetailsService {
private final UserRepository userRepository;
// security session(내부 Authentication(내부 UserDetails))
@Override
public UserDetails loadUserByUsername(String username) throws UsernameNotFoundException {
User user = userRepository.findByUsername(username);
if (username != null) {
return new PrincipalDetails(user);
}
return null;
}
}
여기에 있는 username은 로그인 시 파라미터로 넘어가는 username이기 때문에 이름을 제대로 적어주어야 한다. (이름을 다르게 하고 싶다면 config 파일에서 따로 설정해주면 되긴 하는데 귀찮으니까 그냥 제대로 쓰기.)
return 될 때 자동으로 UserDetails가 만들어지면서 UserDetails는 Authentication 내부에 들어가게 된다.
그리고 Authentication은 security session 내부에 들어간다.
그 다음 로그인을 해보면 로그인이 되면서 defaultSuccessUrl("/")에 의해 index 페이지로 넘어가게 된다.
대신 user 페이지는 접근이 가능하지만 admin이나 manager 페이지에 들어가게 되면 권한이 없기 때문에 403 오류가 뜬다.
권한처리
이제 admin과 manager에 권한을 셋팅해줘야 하는데 현재 회원가입을 하면 ROLE은 USER로만 되어있다.
DB에서 직접 UPDATE 문으로 manager와 admin의 ROLE을 변경해준다.
UPDATE USER SET ROLE = 'ROLE_MANAGER' WHERE username = 'manager';
UPDATE USER SET ROLE = 'ROLE_ADMIN' WHERE username = 'admin';
그 다음 manager로 로그인을 해보면 manager 페이지는 접근할 수 있지만 권한이 없는 admin 페이지에서는 여전히 403 오류가 있는 것을 확인할 수 있다. admin으로 로그인 시 두 페이지에 접근해보면 문제없이 접근이 가능한 것을 확인할 수 있다.
SecurityConfig에서 애노테이션을 한 줄 추가한다.
@EnableGlobalMethodSecurity(securedEnabled = true, prePostEnabled = true)
이 애노테이션은 Spring Security에서 메서드 수준의 보안을 활성화하기 위해 사용된다.
WebSecurityConfigurerAdapter를 확장하는 보안 구성 클래스에 추가되며, 여러 속성값을 통해 다양한 메서드 보안 방식을 활성화할 수 있다.
securedEnabled = true는 @Secured 애노테이션을 사용한 메서드 수준의 보안을 활성화한다.
prePostEnabled = true는 @PreAuthorize나 @PostAuthorize 애노테이션의 활성화를 의미한다. 메서드가 실행되기 전에 보안 표현식을 기반으로 액세스를 제어한다.
@Secured("ROLE_ADMIN")
@GetMapping("/info")
public @ResponseBody String info() {
return "개인정보";
}
@PreAuthorize("hasRole('ROLE_MANAGER') or hasRole('ROLE_ADMIN')")
@GetMapping("/data")
public @ResponseBody String data() {
return "데이터 정보";
}
위와 같은 컨트롤러에 @Secured 애노테이션을 추가하면 /info URL은 로그인 하지 않은 사람은 접근할 수 없게 된다. admin 등급만 접근할 수 있게 된다는 뜻이 된다. 일일이 SecurityConfig에 추가하지 않아도 권한 부여를 간편하게 설정할 수 있다.
@PreAuthorize는 두 개의 권한을 부여할 수 있다. 물론 한 개도 가능하지만 하나만 부여할 거라면 Secured를 쓰는 것이 낫다.
참고로 @PostAuthorize도 있긴 한데 메서드가 실행된 후의 반환 값을 기반으로 보안 결정을 내린다. (잘 사용하지 않는 거 같다.)
HTML 코드
인덱스 페이지
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<title>인덱스 페이지</title>
</head>
<body>
<h1>인덱스 페이지입니다.</h1>
</body>
</html>
로그인 페이지
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<title>로그인 페이지</title>
</head>
<body>
<h1>로그인 페이지</h1>
<hr>
<form action="/login" method="POST">
<input type="text" name="username" placeholder="Name"> <br>
<input type="password" name="password" placeholder="Password"> <br>
<button>로그인</button><br>
</form> <br>
<a href="/joinForm">회원가입을 아직 하지 않으셨나요?</a>
</body>
</html>
회원가입 페이지
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<title>회원가입 페이지</title>
</head>
<body>
<h1>회원가입 페이지</h1>
<hr>
<form action="/join" method="POST">
<input type="text" name="username" placeholder="Name"> <br>
<input type="password" name="password" placeholder="Password"> <br>
<input type="email" name="email" placeholder="Email"> <br>
<button>회원가입</button>
</form> <br>
</body>
</html>
🐣 출처: 인프런 최주호님 강의
이 글은 인프런의 최주호님 SpringBoot Security & JWT 강의를 보고 작성한 글입니다.
강의를 들으면서 정리한 글이므로 틀린 내용이나 오타가 있을 수 있습니다.