질문, 피드백 등 모든 댓글 환영합니다.
본격적으로 이전 블로그에서 설정한 Spring Security를 프로젝트에 적용하겠습니다.
이전까진 로그인과 사용자 인증, 인가를 직접 구현했지만 스프링 시큐리티가 제공하는 기능을 사용하도록 기존 코드를 수정해 주겠습니다.
@Getter
public class LoginDto {
@NotEmpty
private String username;
@NotEmpty
private String password;
}
스프링 시큐리티는 별도의 설정을 하지 않는다면 로그인 id와 비밀번호의 변수명을 규칙 대로 사용해야합니다.
로그인 id : username, 비밀번호 : password 로 사용해야합니다.
LoginDto
가 사용되는 부분 (LoginController
, login/form.html
) 모두 수정해줍니다. (코드 미첨부)
MemberService (MemberSecurityService)
@Service
@Transactional(readOnly = true)
@RequiredArgsConstructor
public class MemberSecurityService implements MemberService {
private final MemberRepository repository;
private final BCryptPasswordEncoder bCryptPasswordEncoder;
@Override
@Transactional
public Long save(Member member) {
member.setEncodingPassword(bCryptPasswordEncoder.encode(member.getPassword()));
// loginId 중복 체크
return repository.findByLoginId(member.getLoginId()).isEmpty()
? repository.save(member).getId() : null;
}
@Override
public List<Member> findAll() {
return repository.findAll();
}
@Override
public Optional<Member> findById(Long id) {
return repository.findById(id);
}
@Override
public Optional<Member> findByLoginId(String loginId) {
return repository.findByLoginId(loginId);
}
}
시큐리티를 사용한 로그인 기능은 BCryptPasswordEncoder
를 이용하여 비밀번호를 암호화 시키므로 회원 가입 시 이를 사용하도록 변경해줍니다.
기존의 인터페이스와 MemberServiceImpl
를 수정(saveBySecurity()
따위를 추가)하여 사용하지 않는 이유는 OOP
때문입니다.
스프링은 객체지향 프레임워크입니다. 때문에 확장에는 열려있고 변경에는 닫혀있음을 의미하는 OCP
원칙에 따라 새로운 구현체를 새로 정의하여 사용한다면 클라이언트 코드 수정 없이 비지니스 로직을 변경할 수 있습니다.
기존에 사용하던 MemberServiceImpl
의 @Sevice
는 주석처리합니다. (코드 미첨부)
HomeController (변경없음)
@PostMapping("/add")
public String save(@Validated @ModelAttribute MemberDto memberDto, BindingResult bindingResult) {
if (!memberDto.getPassword().equals(memberDto.getCheckPassword()))
bindingResult.rejectValue("checkPassword", "wrong");
if (bindingResult.hasErrors()) return "member/add";
if (memberService.saveBySecurity(memberDto) == null) {
bindingResult.reject("duplication");
return "member/add";
}
return "redirect:/";
}
위에서 언급했듯이 OOP
를 준수한 프로젝트이기에 서비스는 변경되었으나
(MemberServiceImpl
-> MemberSecurityService
) 클라이언트 코드인 HomeController
에는 변경사항이 없습니다.
이전에는 HttpSession
을 사용하여 로그인 상태를 유지했습니다. 스프링 시큐리티를 사용하면 HttpSession
을 사용하지 않고 로그인 상태를 유지할 수 있습니다.
Configurer
@Override
public void addInterceptors(InterceptorRegistry registry) {
// registry.addInterceptor(new LoginInterceptor())
// .order(1)
// .addPathPatterns("/**")
// .excludePathPatterns("/", "/login", "/logout", "/add", "/error", "/css/**", "/js/**");
registry.addInterceptor(new ToDoInterceptor(toDoService))
.order(2)
.addPathPatterns("/todo/update/**", "/todo/change/**", "/todo/delete/**");
}
로그인 상태 유지 및 인증 기능은 스프링 시큐리티가 제공하므로 주석 처리합니다.
추가로 LoginController
의 @PostMapping("/login")과 @PostMaaping("/logout") 또한 주석 처리합니다.
ToDoController
@ModelAttribute("toDoDtos")
public List<ToDoDto> toDoDtos(@AuthenticationPrincipal UserDetailsImpl userDetails) {
return getToDoDtos(userDetails.getMember(), false);
}
@ModelAttribute("completedDtos")
public List<ToDoDto> completedDtos(@AuthenticationPrincipal UserDetailsImpl userDetails) {
return getToDoDtos(userDetails.getMember(), true);
}
@PostMapping("/todo/add")
public String addToDo(@Validated @ModelAttribute("toDoDto") ToDoDto toDoDto, BindingResult bindingResult,
@AuthenticationPrincipal UserDetailsImpl userDetails) {
if (bindingResult.hasErrors()) return "todo/add";
Optional<Member> findMember = memberService.findById(userDetails.getMember().getId());
Optional<ToDo> createToDo = findMember.map(member -> ToDo.createToDo(
toDoDto.getTitle(), toDoDto.getDescription(), toDoDto.getDueDate(),
member));
createToDo.ifPresent(toDo -> toDoService.save(toDo));
return "redirect:/todo";
}
@AuthenticationPrincipal
는 인증이 완료된 객체를 UserDetails
로 반환하는 어노테이션입니다.
UserDetails
의 통해 로그인 상태의 Member
객체를 조회할 수 있습니다.
getSessionMember()
는 더이상 사용하지 않으니 주석 처리합니다.
ToDoInterceptor
@Override
public boolean preHandle(HttpServletRequest request, HttpServletResponse response, Object handler) throws Exception {
String requestURI = request.getRequestURI();
// Member loginMember = (Member) request.getSession(false).getAttribute("loginMember");
Object principal = SecurityContextHolder.getContext().getAuthentication().getPrincipal();
UserDetailsImpl userDetails = (UserDetailsImpl) principal;
Member loginMember = userDetails.getMember();
int pos = requestURI.lastIndexOf("/");
Long toDoId = Long.parseLong(requestURI.substring(pos + 1));
Optional<ToDo> findById = toDoService.findById(toDoId);
if (findById.isEmpty() || !findById.get().getMember().getId().equals(loginMember.getId())) {
response.sendRedirect("/todo");
return false;
}
return true;
}
@AuthenticationPrincipal
를 사용하지 않고 static으로 정의된 SecurityContextHolder
을 사용하여 인증된 객체를 조회할 수 있습니다.
더이상 HttpSession
을 사용하지 않으므로 UserDetails
기반으로 화면을 구성할 수 있게 코드를 수정합니다.
HomeController
@GetMapping("/")
public String home() {
return "home";
}
"/"
경로로 접근 시 비 로그인 사용자는 회원 가입과 로그인 버튼을, 로그인 사용자에겐 회원 가입과 할 일, 로그 아웃 버튼을 제공해야 합니다.
LoginController
@GetMapping("/login")
public String loginForm(@ModelAttribute("loginDto") LoginDto loginDto,
@AuthenticationPrincipal UserDetails userDetails) {
return (userDetails == null) ? "login/form" : "redirect:/";
}
로그인 사용자(UserDetails
값이 존재)가 접근 시 "/"
으로
ToDoController
@GetMapping("/todo")
public String todo(@AuthenticationPrincipal UserDetailsImpl userDetails, Model model) {
model.addAttribute("membername", userDetails.getMember().getName());
return "todo/main";
}
"/todo"
에서 로그인 회원의 이름이 필요하므로 model
에 담아주겠습니다.
Home.html
<html xmlns:th="http://www.thymeleaf.org"
xmlns:sec="http://www.thymeleaf.org/extras/spring-security">
...
<div sec:authorize="isAuthenticated()">
<div th:replace="~{header/logout :: logout}"></div>
</div>
...
<button class="w-100 btn btn-secondary btn-lg" sec:authorize="isAnonymous()"
onclick="location.href='items.html'"
th:onclick="|location.href='@{/login}'|" type="button">
로그인
</button>
<button class="w-100 btn btn-secondary btn-lg" sec:authorize="isAuthenticated()"
onclick="location.href='items.html'"
th:onclick="|location.href='@{/todo}'|" type="button">
할 일
</button>
xmlns:sec="http://www.thymeleaf.org/extras/spring-security"
을 포함하면 타임리프에서 스프링 시큐리티가 제공하는 기능을 사용할 수 있습니다.
인증 시 sec:authorize
를 사용할 수 있고 로그인 시 isAuthenticated()
는 true를, isAnonymous()
는 false를 반환합니다. (반대도 성립)
main.html
<h1 class="text-center" th:text="|${membername}의 ToDo List|"></h1>
로그인 로직을 스프링 시큐리티에 위임하니 로그인 실패 시 에러 메시지가 화면에 출력되는 기능이 누락되었습니다.
다음 블로그에선 disable()로 처리한 csrf를 적용하도록하고 로그인 실패 시 에러메시지 출력하는 로직을 추가하겠습니다.