이 시리즈에 나오는 모든 내용은 인프런 인터넷 강의 - 스프링 시큐리티 - Spring Boot 기반으로 개발하는 Spring Security - 에서 기반된 것입니다. 그리고 여기서 인용되는 PPT 이미지 또한 모두 해당 강의에서 가져왔음을 알립니다. 추가적으로 여기에 작성된 코드들 또한 해당 강의의 github 에 올라와 있는 코드를 참고해서 만든 겁니다.
저번 글에서 작성했던 Spring Security Config 소스를 다시 한번 보자.
@Configuration
@EnableWebSecurity
@Slf4j
public class SecurityConfig extends WebSecurityConfigurerAdapter {
@Bean
public PasswordEncoder passwordEncoder() {
return new BCryptPasswordEncoder();
}
// ... 생략 ...
}
PasswordEncoder
는 이름 그대로 비밀번호 암호화를 책임지는 클래스의 인터페이스다.
이 인터페이스의 핵심 메소드는 아래와 같다.
public interface PasswordEncoder {
String encode(CharSequence rawPassword);
boolean matches(CharSequence rawPassword, String encodedPassword);
}
그렇다면 PasswordEncoder 를 직접 구현해야할까? 물론 그럴 수도 있겠지만,
Spring Security
는 이미 PasswordEncoder
인터페이스를 구현한 다수의
클래스를 제공한다. 실습에서는 BCryptPasswordEncoder
를 사용할 것이다.
그리고 이 타입의 객체를 빈으로 등록함으로써 추후에 다른 Bean 에서 사용할 수 있도록 하자.
회원가입시에 비밀번호를 암호화해서 저장한다.
@Controller
@RequiredArgsConstructor
public class UserController {
private final UserService userService;
private final PasswordEncoder passwordEncoder; // DI!
// ... 생략 ...
@GetMapping("/users")
public String createUser() {
return "user/login/register";
}
@PostMapping("/users")
public String createUser(AccountDto accountDto) {
ModelMapper modelMapper = new ModelMapper();
Account account = modelMapper.map(accountDto, Account.class);
account.setPassword(passwordEncoder.encode(account.getPassword()));
userService.createUser(account);
return "redirect:/";
}
}
이러고 실제 insert 를 하게되면 아래처럼 비밀번호가 암호화가 되어서 들어간 것을 확인할 수 있다.
이제는 DB를 통해서 사용자 정보를 읽을 것이기 때문에
기존에 Spring Security 설정 클래스에서 작성했던 메모리 방식의 사용자 정보 등록 코드를 지울 것이다. 아래처럼 주석처리 한다.
@Configuration
@EnableWebSecurity
@Slf4j
public class SecurityConfig extends WebSecurityConfigurerAdapter {
// @Override
// protected void configure(AuthenticationManagerBuilder auth) throws Exception {
//
// String password = passwordEncoder().encode("1111");
// auth.inMemoryAuthentication().withUser("user").password(password).roles("USER");
// auth.inMemoryAuthentication().withUser("manager").password(password).roles("MANAGER");
// auth.inMemoryAuthentication().withUser("admin").password(password).roles("ADMIN");
// }
// ... 생략 ...
}
package me.dailycode.security.service;
import lombok.RequiredArgsConstructor;
import me.dailycode.domain.Account;
import me.dailycode.repository.UserRepository;
import org.springframework.security.core.authority.SimpleGrantedAuthority;
import org.springframework.security.core.userdetails.UserDetails;
import org.springframework.security.core.userdetails.UserDetailsService;
import org.springframework.security.core.userdetails.UsernameNotFoundException;
import java.util.ArrayList;
import java.util.List;
@Service("userDetailsService")
@RequiredArgsConstructor
public class CustomUserDetailsService implements UserDetailsService {
private final UserRepository userRepository;
@Override
public UserDetails loadUserByUsername(String username) throws UsernameNotFoundException {
Account account = userRepository.findByUsername(username);
if (account == null) {
throw new UsernameNotFoundException("UsernameNotFoundException");
}
List<SimpleGrantedAuthority> roles = new ArrayList<SimpleGrantedAuthority>();
roles.add(new SimpleGrantedAuthority("ROLE_USER"));
return new AccountContext(account, roles);
}
}
package me.dailycode.security.service;
import me.dailycode.domain.Account;
import org.modelmapper.ModelMapper;
import org.springframework.security.core.GrantedAuthority;
import org.springframework.security.core.userdetails.User;
import java.util.Collection;
public class AccountContext extends User {
private final Account account;
private final ModelMapper modelMapper = new ModelMapper();
public AccountContext(Account account, Collection<? extends GrantedAuthority> authorities) {
super(account.getUsername(), account.getPassword(), authorities);
// JPA 영역과 완전한 분리를 위해서 복사해서 새로운 Account 인스턴스를 생성한다.
this.account = modelMapper.map(account, Account.class);
}
public Account getAccount() {
return account;
}
}
@Configuration
@EnableWebSecurity
@Slf4j
public class SecurityConfig extends WebSecurityConfigurerAdapter {
//... 생략 ...
// @Override
// protected void configure(AuthenticationManagerBuilder auth) throws Exception {
//
// String password = passwordEncoder().encode("1111");
// auth.inMemoryAuthentication().withUser("user").password(password).roles("USER");
// auth.inMemoryAuthentication().withUser("manager").password(password).roles("MANAGER");
// auth.inMemoryAuthentication().withUser("admin").password(password).roles("ADMIN");
// }
@Autowired
private UserDetailsService userDetailsService;
@Override
protected void configure(AuthenticationManagerBuilder auth) throws Exception {
auth.userDetailsService(userDetailsService);
}
}
위에서 만든 커스텀 UserDetailsService 를 사용하는 CustomAuthenticationProvider를 생성해보자.
package me.dailycode.security.provider;
import me.dailycode.security.service.AccountContext;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.security.authentication.AuthenticationProvider;
import org.springframework.security.authentication.BadCredentialsException;
import org.springframework.security.authentication.UsernamePasswordAuthenticationToken;
import org.springframework.security.core.Authentication;
import org.springframework.security.core.AuthenticationException;
import org.springframework.security.core.userdetails.UserDetails;
import org.springframework.security.core.userdetails.UserDetailsService;
import org.springframework.security.crypto.password.PasswordEncoder;
public class CustomAuthenticationProvider implements AuthenticationProvider {
@Autowired
private UserDetailsService userDetailsService;
@Autowired
private PasswordEncoder passwordEncoder;
@Override
public Authentication authenticate(Authentication authentication) throws AuthenticationException {
String username = authentication.getName();
String password = (String)authentication.getCredentials();
AccountContext accountContext = (AccountContext) userDetailsService.loadUserByUsername(username);
if (!passwordEncoder.matches(password, accountContext.getPassword())) {
throw new BadCredentialsException("BadCredentialsException'");
}
return new UsernamePasswordAuthenticationToken(
accountContext.getAccount(),
null,
accountContext.getAuthorities()
);
}
@Override
public boolean supports(Class<?> authentication) {
return UsernamePasswordAuthenticationToken.class.isAssignableFrom(authentication);
}
}
참고: 개인적 궁금증에 대한 자문자답
Q. Authenticaion.principal 에 Account 정보를 준 이유는? 그냥 aacountContext 를 주면 되는 거 아닌가?
A. https://stackoverflow.com/questions/37499307/whats-the-principal-in-spring-security 를 참고하면 principal 은 현재 로그인 사용자 자체를 의미하는 것이다.
그러므로 Wrap 한 클래스가 아닌 그냥 순수한 사용자 데이터를 넣기 위해서다.
하지만 이건 어디까지나 구현하는 사람의 마음이다.
Spring Security 설정 클래스에 만든 CustomAuthenticationProvider
를 적용하자.
// @Bean
// public UserDetailsService userDetailsService() {
// return new CustomUserDetailsService();
// }
// @Autowired
// private UserDetailsService userDetailsService;
//
// @Override
// protected void configure(AuthenticationManagerBuilder auth) throws Exception {
// auth.userDetailsService(userDetailsService);
// }
protected void configure(AuthenticationManagerBuilder auth) throws Exception {
auth.authenticationProvider(authenticationProvider());
}
@Bean
public AuthenticationProvider authenticationProvider() {
return new CustomAuthenticationProvider();
}
@Configuration
@EnableWebSecurity
@Slf4j
public class SecurityConfig extends WebSecurityConfigurerAdapter {
// ... 생략...
@Override
protected void configure(HttpSecurity http) throws Exception {
http
.authorizeRequests()
.antMatchers("/", "/users").permitAll()
.antMatchers("/mypage").hasRole("USER")
.antMatchers("/messages").hasRole("MANAGER")
.antMatchers("/config").hasRole("ADMIN")
.anyRequest().authenticated()
.and()
.formLogin()
.loginPage("/login") // 커스텀 로그인 페이지 설정!
.loginProcessingUrl("/login_proc") // 로그인 post url 지정
.defaultSuccessUrl("/") // 로그인 성공 티폴트 redirect 경로
.permitAll(); // 커스텀 로그인 페이지를 설정했으니 permitAll 해준다.
}
// ... 생략...
}
<!DOCTYPE html>
<html xmlns:th="http://www.thymeleaf.org">
<head th:replace="layout/header::userHead"></head>
<body>
<div th:replace="layout/top::header"></div>
<div class="container text-center">
<div class="login-form d-flex justify-content-center">
<div class="col-sm-5" style="margin-top: 30px;">
<div class="panel">
<p>아이디와 비밀번호를 입력해주세요</p>
</div>
<form th:action="@{/login_proc}" class="form-signin" method="post">
<div class="form-group">
<input type="text" class="form-control" name="username" placeholder="아이디" required="required" autofocus="autofocus" />
</div>
<div class="form-group">
<input type="password" class="form-control" name="password" placeholder="비밀번호" required="required" />
</div>
<!--
<div class="form-group">
Remember Me<input type="checkbox" name="remember-me" />
</div>-->
<button type="submit" class="btn btn-lg btn-primary btn-block">로그인</button>
</form>
</div>
</div>
</div>
</body>
</html>
<!DOCTYPE html>
<html lang="ko" xmlns:th="http://www.thymeleaf.org">
<head th:fragment="userHead">
<meta http-equiv="Content-Type" content="text/html; charset=UTF-8" />
<title>Home</title>
<script th:src="@{/js/jquery-2.1.3.min.js}"></script>
<link rel="stylesheet" th:href="@{/css/base.css}" />
<link rel="stylesheet" th:href="@{https://stackpath.bootstrapcdn.com/bootstrap/4.4.1/css/bootstrap.min.css}" />
</head>
</html>
package me.dailycode.controller.login;
import org.springframework.stereotype.Controller;
import org.springframework.web.bind.annotation.GetMapping;
@Controller
public class LoginController {
@GetMapping("/login")
public String login() {
return "user/login/login";
}
}
로그아웃 방법은 크게 2가지다.
2번째 방법을 사용하도록 할 것이다.
<!DOCTYPE html>
<html lang="ko" xmlns:th="http://www.thymeleaf.org"
xmlns:sec="http://www.thymeleaf.org/thymeleaf-extras-springsecurity5">
<!-- ns 추가 -->
<div th:fragment="header">
<nav class="navbar navbar-dark sticky-top bg-dark ">
<div class="container">
<a class="text-light" href="#"><h4>Core Spring Security</h4></a>
<ul class="nav justify-content-end">
<li class="nav-item" sec:authorize="isAnonymous()" ><a class="nav-link text-light" th:href="@{/login}">로그인</a></li>
<!-- 로그아웃 버튼 추가 -->
<li class="nav-item" sec:authorize="isAuthenticated()"><a class="nav-link text-light" th:href="@{/logout}">로그아웃</a></li>
<li class="nav-item" sec:authorize="isAnonymous()"><a class="nav-link text-light" th:href="@{/users}">회원가입</a></li>
<li class="nav-item" ><a class="nav-link text-light" href="/">HOME</a></li>
</ul>
</div>
</nav>
</div>
</html>
import org.springframework.security.core.Authentication;
import org.springframework.security.core.context.SecurityContextHolder;
import org.springframework.security.web.authentication.logout.SecurityContextLogoutHandler;
import org.springframework.stereotype.Controller;
import org.springframework.web.bind.annotation.GetMapping;
import javax.servlet.http.HttpServletRequest;
import javax.servlet.http.HttpServletResponse;
@Controller
public class LoginController {
@GetMapping("/login")
public String login() {
return "user/login/login";
}
@GetMapping("/logout")
public String logout(HttpServletRequest request, HttpServletResponse response) {
Authentication authentication =
SecurityContextHolder.getContext().getAuthentication();
if (authentication != null) {
new SecurityContextLogoutHandler().logout(request, response, authentication);
}
return "redirect:/login";
}
}
new SecurityContextLogoutHandler().logout(request, response, authentication);
처럼 호출만 하면 끝!
실제 저 버튼을 누르면 로그아웃 처리가 된다.
- 참고: https://inflearn.com/questions/33806
- 이 래퍼런스에서는 알 수 있는 정보가 많으니 한번쯤 참고하자.