고딩왕 코범석
Security 1. 기초 설정 ~ 로그인 본문
안녕하세요! 이번 포스팅에서는 데어 프로그래밍님 유튜브에서 Security파트 강의를 듣고 학습한 내용을 포스팅하겠습니다. 항상 피드백은 환영하구 바로 시작하겠습니다! 미리 말씀드리지만 시큐리티의 구조와 동작원리는 다른 분들이 자세하게 설명하셨기에 저의 포스팅에서는 바로 적용하는 것 부터 살펴볼게요!
Spring Security?
스프링 시큐리티란, 스프링 기반의 애플리케이션 보안(인증과 권한)을 담당하는 프레임워크 입니다. 시큐리티를 사용할 경우, 보안과 관련해서 체계적으로 많은 옵션들로 부터 도움을 받을 수 있습니다. 참고로 시큐리티는 filter 기반으로 동작하기에, spring mvc와 분리되어 동작합니다! 시큐리티를 설명하기 앞서 보안관련 용어부터 정리해보겠습니다.
보안관련 용어 정리
- 접근 주체 (Principal) : 보호된 대상에 접근하는 유저
- 인증 (Authenticate) : 현재 유저가 누구인지 확인(로그인), 애플리케이션의 작업을 수행할 수 있는 주체임을 증명하는 과정
- 인가 (Authorize) : 현재 유저가 어떤 서비스, 페이지에 접근할 수 있는 권한이 있는지 검사
- 권한 : 인증된 주체가 어떤 애플리케이션 동작을 수행할 수 있도록 허락되었는지를 결정한다.
설정
우선, 시큐리티 의존성을 받은 다음 서버를 키고 메인 url을 들어가게 되면 Login 창이 뜰 것입니다. 모든 리소스에 대해 인증된 회원만 접근이 가능하기 때문이고, 시큐리티 설정을 따로 해주어야 login 하지 않은 회원도 main 페이지에 들어갈 수 있습니다.
@Configuration @EnableWebSecurity public class SecurityConfig extends WebSecurityConfigurerAdapter { @Bean public BCryptPasswordEncoder bCryptPasswordEncoder() { return new BCryptPasswordEncoder(); } @Override protected void configure(HttpSecurity http) throws Exception { http.csrf().disable(); http.authorizeRequests() .antMatchers("/user/**").authenticated() .antMatchers("/manager/**").access("hasRole('ROLE_ADMIN') or hasRole('ROLE_MANAGER')") .antMatchers("/admin/**").access("hasRole('ROLE_ADMIN')") .anyRequest().permitAll() .and() .formLogin() .loginPage("/loginForm") .loginProcessingUrl("/login") .defaultSuccessUrl("/"); } }
- 이 클래스가 시큐리티 관련 설정임을 스프링에게 알려주기 위해 클래스 레벨에 @Configuration, @EnableWebSecurity, 시큐리티 설정 클래스에서 WebSecurityConfigurerAdapter를 상속받고 configure(HttpSecurity http) 를 오버라이드 해줍니다. EnableWebSecurity 어노테이션의 역할은 스프링 시큐리티 필터(설정)이 스프링 필터 체인에 등록되게끔 하는 역할입니다.
- csrf는 우선 이 프로젝트에서 disable 해줍니다. csrf란 Cross-site request forgery의 약자로 타 사이트에서 본인의 사이트로 form 데이터를 사용해 공격하려 할 때, 방지하기 위해 csrf 토큰값을 사용하는 것입니다. 타임리프로 form 생성시 타임리프, mvc, 시큐리티가 조합되어 자동으로 csrf 토큰 기능을 지원하며, 개발자 도구로 확인했을 때, form을 타임리프로 작성했을 경우 자동으로 hidden csrf 토큰 값이 생성되어있습니다.
- antMatchers : 경로를 파라미터에 적습니다.
- .antMatchers("/user/**").authenticated() : /user 경로에 속한 모든 정보들은 authenticated(인증된, 로그인 된!)된 사람만 들어올 수 있습니다.
- antMatchers("/manager/**").access("hasRole('ROLE_ADMIN') or hasRole('ROLE_MANAGER')") : /manager 경로에 속한 모든 정보들은 ADMIN 권한이나 MANAGER 권한을 가진 사람만 이용할 수 있습니다.
- antMatchers("/admin/**").access("hasRole('ROLE_ADMIN')") : /admin 경로에 속한 모든 정보들은 ADMIN 권한을 가진 사람만 이용할 수 있습니다.
- anyRequest().permitAll() : 그 외에 나머지 요청들은 인증 권한에 상관 없이 모두 들어갈 수 있습니다.
- formLogin().loginPage("/loginForm") : 인증이 되지 않은 회원일 경우, 이 메서드에 설정된 리소스(여기서는 loginForm.html)로 이동하게 해줍니다.
- loginProcessingUrl("/login") : /login 을 요청할 경우 시큐리티가 가로채서 대신 로그인을 진행해줍니다. 디폴트 action = "/login", method = "POST"입니다.
- defaultSuccessUrl("/") : 우리가 직접 앞에서 설정한 loginPage를 들어가서 로그인 성공했을 경우 설정한 url("/")로 이동합니다. 만약 권한이 없는 상태(로그인 하지 않은 상태)로 어떤 페이지에 들어갔는데 loginPage요청에 의해 로그인 폼으로 들어가고, 로그인이 성공한 다음 이 로그인한 회원의 권한도 원래 요청했던 url의 권한을 충족시킬 경우 "/"가 아닌 클라이언트가 원했던 경로로 이동시켜 줍니다.
- 시큐리티가 동작하려면 사용자의 비밀번호를 암호화 해야합니다. 그렇기 때문에 BCryptPasswordEncoder를 빈으로 등록해주겠습니다.
회원가입, 로그인
이제 회원가입과 로그인을 본격적으로 볼게요. html 페이지를 다음과 같이 만들어보겠습니다.
<!-- index.html --> <!DOCTYPE html> <html lang="en"> <head> <meta charset="UTF-8"> <title>인덱스 페이지</title> </head> <body> <h1>인덱스 페이지입니다.</h1> </body> </html> <!-- joinForm.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="Username" /><br/> <input type="password" name="password" placeholder="Password" /><br/> <input type="email" name="email" placeholder="Email" /><br/> <button>회원가입</button> </form> </body> </html> <!-- loginForm.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="Username" /> <input type="password" name="password" placeholder="Password" /> <button>로그인</button> </form> <a href="/joinForm">회원가입</a> </body> </html>
User의 도메인 입니다.
@Entity @Getter @Setter @NoArgsConstructor @AllArgsConstructor @Builder public class User { @Id @GeneratedValue private Long id; private String username; private String password; private String email; private String role; @CreationTimestamp //회원 가입시 자동으로 가입한 시간, 날짜 기입 private LocalDateTime createdDate; }
다음은 Controller입니다.
@Controller @RequiredArgsConstructor public class IndexController { private final UserRepository userRepository; private final BCryptPasswordEncoder bCryptPasswordEncoder; @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의 anyRequest.permitAll()에 의해 // 시큐리티 필터가 낚지 않는다. @GetMapping("/loginForm") public String loginForm() { return "loginForm"; } @GetMapping("/joinForm") public String joinForm() { return "joinForm"; } @PostMapping("/join") public String join(User user) { user.setRole("ROLE_USER"); user.setPassword(bCryptPasswordEncoder.encode(user.getPassword())); userRepository.save(user); return "redirect:/loginForm"; } }
permitAll에 해당하는 url들은 시큐리티 필터가 검사하지 않습니다. 또한, 비밀번호 암호화를 위해 bCryptPasswordEncoder.encode로 기존의 회원가입한 회원의 비밀번호를 암호화 처리 후 save하겠습니다.
public interface UserRepository extends JpaRepository<User, Long> { User findByUsername(String username); }
findByUsername은 로그인 시 필요하기 때문에 미리 만들어 두었습니다. 이제 로그인을 하기 위해 클래스들을 작성해보겠습니다.
@AllArgsConstructor public class PrincipalDetails implements UserDetails { private User 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; } }
앞에서 로그인 폼 화면의 form action = "/login", method = "POST" 그리고 시큐리티 config에서 loginProcessingUrl("/login")을 설정했습니다. 이렇게 설정하고 시큐리티에서 로그인 과정들이 어떻게 진행되는지 살펴봐야합니다.
시큐리티가 post로 된 /login 요청 주소를 낚아채서 로그인을 진행하는데, 로그인이 완료되었을 때, 시큐리티 자신만의 세션공간인 SecurityContextHolder에 세션을 저장합니다. 이 세션은 로그인을 완료한 회원의 정보들이며, Authentication 타입의 객체만 들어가게 됩니다.
Authentication 안에는 User정보가 들어가 있습니다. User 객체의 타입은 UserDetails 타입 객체여야 합니다. 그렇기 때문에 PrincipalDetails 클래스를 생성했을 때, UserDetails 인터페이스를 구현했고, PrincipalDetails 클래스 내에 User 객체를 필드에 넣은 것입니다. 이제 오버라이드한 메서드들을 하나씩 살펴볼게요!
- getAuthorities() : GrantedAuthority 타입을 담는 컬렉션을 반환합니다. GrantedAuthority는 해당 유저의 권한들을 String으로 반환해주는 메서드를 갖고 있어 이를 user.getRole()을 통해 유저의 권한을 GrantedAuthority 타입으로 감싸서 컬렉션으로 만든 후 리턴해주는 메서드 입니다.
- getUsername, Password는 무슨 의미인지 아실 것 같아서 넘어가겠습니다.
- isAccountNonExpired() : 해당 계정이 만료되지 않았는지 확인하는 메서드며, true를 반환합시다.
- isAccountNonLocked() : 해당 계정이 잠기지 않았는지 확인하는 메서드며, true를 반환합시다.
- isCredentialNonExpired() : 해당 계정의 자격 증명이 만료되지 않았는지 확인하는 메서드며, true를 반환합시다.
- isEnabled() : 계정의 사용여부를 나타냅니다. true를 반환해줍시다.
그리고 로그인을 진행하는 서비스인 PrincipalDetailsService를 만들어보겠습니다.
@Service @RequiredArgsConstructor public class PrincipalDetailsService implements UserDetailsService { private final UserRepository userRepository; @Override public UserDetails loadUserByUsername(String username) throws UsernameNotFoundException { User user = userRepository.findByUsername(username); if (user != null) { return new PrincipalDetails(user); } return null; } }
시큐리티가 로그인을 처리할 때, UserDetailsService 타입의 컴포넌트의 loadUserByUsername 메서드로 처리합니다. 이때 로그인 폼의 input 태그에 name 속성을 아이디의 경우 username, 비밀번호의 경우 password로 해야합니다. 그래야 시큐리티에서 로그인 할 아이디와 비밀번호로 인식되기 때문입니다.
repository에서 아이디가 존재할 경우 아이디를 가져와 앞에 만들었던 PrincipalDetails 객체에 찾은 user객체를 넣어주고 리턴해주고 그렇지 않은 경우는 null을 반환해줍니다. 이 메서드 리턴의 의미는 시큐리티 컨텍스트에 해당 유저를 넣겠다는 의미입니다.
이렇게 아주 간단하게 시큐리티로 회원가입과 로그인을 해보았습니다. 저도 강의를 들으면서 이 포스팅을 계속 해볼 예정입니다. 항상 피드백은 환영합니다!
참조했던 글들
'Language & Framework > Spring' 카테고리의 다른 글
비동기 이벤트를 테스트하는 방법 With JUnit (0) | 2021.04.10 |
---|---|
JWT를 적용하기 전에 알아야 할 것들 (0) | 2021.03.02 |
@Async, 비동기 기능 (0) | 2021.02.16 |
ResponseEntity란? (4) | 2021.02.08 |
JUnit과 계층별 단위 테스트 정리 (0) | 2021.01.31 |