간단한 CRUD기능을 구현한 커뮤니티에 필요한 기능을 하나씩 추가해보려고 한다.
첫번째는 한번도 건드려본적이 없던 스프링 시큐리티를 이용해 회원가입과 로그인을 구현해보려고 한다.
Spring Security 간단설명
Spring Security는 스프링 기반의 애플리케이션에서 인증과 권한을 통해 접근을 제어하는 프레임워크라고 하는데 대략 어디에 어떻게 쓰는지 간단하게 공부한 내용을 정리하고 간다.
보통 스프링을 처음 접하면 아래와 같이 클라이언트와 소통한다고 생각한다.
하지만 실제로 클라이언트와 실질적인 소통을 하는 것 서블렛 컨테이너(톰캣서버)이고 서블렛 컨테이너를 거쳐 스프링 디스패쳐 서블렛이 받으면 그때 스프링 공부할 때 배웠던 핸들러 맵핑, 핸들러 어댑터를 통해서 컨트롤러로 접근해 뷰 리졸버로 뷰를 만들어 서블렛 컨테이너로 보내면 서블렛 컨테이너는 그걸 다시 클라이언트에 보내주는 형식이다.
여기서 서블렛 컨테이너와 스프링 디스패쳐 서블렛 사이에 필터를 사용해 request나 response를 조작할 수 있다.
클라이언트와 컨트롤러 사이에서 request나 response를 조작하는 방법은
1. 서블렛 컨테이너와 서블렛 디스패처 사이의 Filter
2. 서블렛 디스패처와 컨트롤러 사이의 Interceptor
3. Interceptor와 컨트롤러 사이의 AOP
등이 있는데 스프링 시큐리티는 여기서 1번의 Filter를 사용해서 인증(로그인), 인가(접속권한)를 구현한다.
하지만 필터는 서블렛 컨테이너가 만들어주므로 스프링 IOC컨테이너 내의 빈에 접근할 수 없기 때문에 스프링 시큐리티는 스프링에게 필터를 만드는 걸 위임해서 SecurityFilterChain을 빈으로 등록한다.
스프링 시큐리티에서 만든 필터는 로그인이 필요한 경로, 특정 권한이 필요한 경로의 요청에 설정 파일, 어노테이션 등으로 접근을 컨트롤 할 수 있게 해준다.
이 글의 목적은 스프링 시큐리티를 이용해 회원가입, 로그인을 구현하고 글 작성, 삭제, 수정등에 접근을 컨트롤하는 것이다.
스프링 시큐리티 Build
implementation 'org.springframework.boot:spring-boot-starter-security' // gradle
<dependency> // maven
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-security</artifactId>
<version>2.3.3.RELEASE</version>
</dependency>
Web Security Config
@Configuration
@RequiredArgsConstructor
public class WebSecurityConfig {
@Bean
public SecurityFilterChain
securityFilterChain(HttpSecurity http) throws Exception {
http.authorizeRequests((requests) -> requests
.antMatchers("/posts/**", "/account/signup", "/css/**").permitAll()
.anyRequest().authenticated())
.formLogin((form) -> form
.loginPage("/account/login").permitAll()
.defaultSuccessUrl("/posts")
.failureUrl("/account/login?error"))
.logout().permitAll();
return http.build();
}
@Bean
public PasswordEncoder passwordEncoder() {
return new BCryptPasswordEncoder();
}
}
인증이 필요한 주소와 필요없는 주소, 로그인, 로그아웃을 POST로 받을 주소 설정과 로그인, 로그아웃 이후 핸들러도 설정할 수 있다.
passwordEncoder는 회원가입, 로그인할때 전반적으로 사용하기 위해 빈으로 등록한다.
- securityFilterChain(HttpSecurity http):
- HttpSecurity를 통해 Http요청에대한 접근권한을 설정할 수 있다.
- authorizedRequests():
- antMatchers() 메소드로 특정경로를 지정하고, permitAll(), hasRole(), authenticated()등으로 접근 설정
- anyRequests() 특정경로로 지정하지 않은 모든 경로 포함
- formLogin():
- loginPage(): 별도의 설정이 없으면 "/login", 해당 경로로 POST 요청이 오면 이후 구현한 AuthenticationFilter가 가로채 로그인 과정을 진행한다.
- defaultSuccessUrl(): 로그인 성공시 이동 경로
- failureUrl() : 로그인 실패시 이동 경로
- logout():
- 별도의 설정이 없으면 "/logout", 해당 경로로 POST 요청이 오면 헤더에 있는 세션 ID를 통해 해당 세션을 삭제
전체적인 설정 메소드를 등록하기 위해 클래스에 @Configuraiton을 걸어준다.
회원가입 흐름
UserRepository
public interface UserRepository extends JpaRepository<User, Long> {
Optional<User> findByUsername(String username);
}
이후 로그인 할 때 DB에서 값을 가져오기 위한 findByUsername 메소드 추가
UserService
@Service
@RequiredArgsConstructor
public class UserService {
private final UserRepository userRepository;
private final PasswordEncoder passwordEncoder;
public void save(UserCreate userCreate){
String encodedPassword = passwordEncoder.encode(userCreate.getPassword());
User registerUser = User.builder()
.username(userCreate.getUsername())
.password(encodedPassword)
.role(Roles.USER)
.build();
userRepository.save(registerUser);
}
}
위의 설정파일에서 등록한 passwordEncoder를 주입받아 DTO에서 엔티티로 옮기면서 비밀번호를 암호화 해주고 저장한다.
AccountController
@RequiredArgsConstructor
@Controller
@RequestMapping("/account")
public class AccountController {
private final UserService userService;
@GetMapping("/signup")
public String signupForm(){
return "account/signup";
}
@PostMapping("/signup")
public String signup(UserCreate user){
userService.save(user);
return "redirect:/account/login";
}
}
로그인 흐름
1. Web Security Config에서 설정한 로그인 URI로 POST 요청을 보내면 Security Filter Chain 내의 UsernamePasswordAuthentication Filter에서 UsernamePasswordAuthenticationToken을 만든다.
2. 만든 토큰을 Authentication Manager에 전달하면
AuthenticationManager는 Provider 구현체인 DaoAuthenticationProvider에 접근한다.
3. Provider에선 UserDetailsService를 이용해 loadUserByUsername()을 통해 DB에 접근해 1번에서 만든 토큰의 Username의 UserDetails를 반환한다.
4. Provider는 3번에서 얻은 UserDetials와 1번에서 만든 UsernamePassword 토큰에 PasswordEncoder를 적용한 값과 비교하는 인증 절차를 거친 후 인증결과를 Manager에게 반환한다.
5.
인증이 성공이면
SessionAuthenticationStrategy 가 새 로그인을 알린다.
Security Context Holder에 UsernamePasswordToken을 등록한다.
AuthenticationSuccessHandler 호출 되어 Web Security Config에서 설정한 인증 성공 핸들러로 이동한다.
이후는 default Success Url로 이동
인증이 실패하면ㅇㄹㄹㄹ
Security Context Holder가 지워진다.
AuthenticationFailureHandler가 호출되어 Web Security Config에서 설정한 인증 실패 핸들러로 이동한다.
이후는 Login failure Url로 이동
6. 로그인 성공하면 클라이언트는 서블렛 컨테이너에게 JSESSIONID를 받고 이를 헤더에 넣고 요청해야 스프링 시큐리티가 AuthenticateToken 발급해줘 접근을 컨트롤하는 페이지에 접근 가능하다.
UserDetailService
@RequiredArgsConstructor
@Service
public class CustomUserDetailService implements UserDetailsService {
private final UserRepository userRepository;
@Override
public UserDetails loadUserByUsername(String username) throws UsernameNotFoundException {
User user = userRepository.findByUsername(username).orElseThrow(() ->
new UsernameNotFoundException(username));
return new CustomUserDetails(user);
}
}
Repository에 구현해놓은 findByUsername으로 DB에 접근해 CustomUserDetails 객체 반환
UserDetailService 구현체를 빈 등록(@Service 내의 @Component로 자등등록)해놓으면 시큐리티 설정파일에서 따로 HttpSecurity 파라미터를 받는 configure 메소드를 작성할 필요가 없다.
UserDetails
@RequiredArgsConstructor
public class CustomUserDetails implements UserDetails {
private final User user;
@Override
public Collection<? extends GrantedAuthority> getAuthorities() {
Collection<GrantedAuthority> collectors = new ArrayList<>();
collectors.add(() -> user.getRole().getAuth());
return collectors;
}
@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;
}
}
기본 유저 정보외에도 계정 만료, 계정잠김, 비밀번호 만료, 사용자 활성 여부등을 설정할 수 있다. username, password와 유저 권한 정보만 저장한다. 로그인 성공시엔 UserDetails가 담긴 Authentication 구현한 UsernamePasswordTokend을Security Context Holder에 저장해 인증객체를 전역적으로 사용할 수 있게 된다.
Login thymeleaf
<form class="form-signin" th:action="@{/account/login}" method="post">
<img class="mb-4" src="https://cdn-icons-png.flaticon.com/128/3129/3129291.png" alt="" width="72" height="72">
<h1 class="h3 mb-3 font-weight-normal">Please sign in</h1>
<div th:if="${param.error}" class="alert alert-danger" role="alert">
Invalid username and password.
</div>
<div th:if="${param.logout}" class="alert alert-warning" role="alert">
You have been logged out.
</div>
<label for="username" class="sr-only"></label>
<input type="text" id="username" name="username" class="form-control" placeholder="username" required autofocus>
<label for="inputPassword" class="sr-only"></label>
<input type="password" id="inputPassword" name="password" class="form-control" placeholder="Password" required>
<button class="btn btn-lg btn-primary btn-block" type="submit">Sign in</button>
<p class="mt-5 mb-3 text-muted">© 2017-2022</p>
</form>
Login POST 요청이 시큐리티 설정 파일의 로그인 주소로 요청된다. form - name 값으로 전송
Header thymeleaf
<!DOCTYPE html>
<html xmlns:th="http://www.thymeleaf.org" xmlns:sec="http://www.thymeleaf.org/extars/spring-security">
,,,
<nav class="navbar navbar-expand-md navbar-dark fixed-top bg-dark " th:fragment="menu">
<div class="collapse navbar-collapse">
<a class="navbar-brand" th:href="@{/board/posts}">Emotion Community</a>
</div>
<a class="btn btn-secondary my-2 my-sm-0" th:href="@{/account/login}"
sec:authorize="!isAuthenticated()">로그인</a>
<a class="btn btn-secondary my-2 my-sm-0" th:href="@{/account/signup}"
sec:authorize="!isAuthenticated()">회원가입</a>
<form class="form-check-inline my-2 my-lg-0" sec:authorize="isAuthenticated()"
th:action="@{/logout}" method="post">
<sapn class="text-white" sec:authentication="name">사용자</sapn>
<sapn class="text-white">님 안녕하세요</sapn>
<button class="btn btn-secondary my-2 my-sm-0" type="submit">로그아웃</button>
</form>
</nav>
thymeleaft security의 isAuthenticated() 메소드로 인증이 안됐으면 로그인, 회원가입 버튼을, 인증이 됐다면 사용자 이름과 로그아웃버튼을 활성화해준다.
결과
미인증 사용자
인증 사용자
개선
자신의 글만 수정, 삭제 가능하게 하기 >>https://anythingis.tistory.com/83
로그인, 회원가입 유효성 검사회원가입 중복 검사
https://anythingis.tistory.com/87
참고
https://www.baeldung.com/spring-security-login
https://docs.spring.io/spring-security/reference/servlet/authentication/passwords/form.html
https://parkmuhyeun.github.io/study/spring%20security/2022-02-06-Spring-Security(2)/