Spring Example: ToDo List #12 Spring Security 적용 1

함형주·2022년 11월 29일
0

Spring Example: ToDo

목록 보기
13/16

질문, 피드백 등 모든 댓글 환영합니다.

본격적으로 이전 블로그에서 설정한 Spring Security를 프로젝트에 적용하겠습니다.

기존 코드 리팩토링

이전까진 로그인과 사용자 인증, 인가를 직접 구현했지만 스프링 시큐리티가 제공하는 기능을 사용하도록 기존 코드를 수정해 주겠습니다.

LoginDto

@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를 적용하도록하고 로그인 실패 시 에러메시지 출력하는 로직을 추가하겠습니다.


github , 배포 URL (첫 접속 시 로딩이 걸릴 수 있습니다.)

profile
평범한 대학생의 공부 일기?

0개의 댓글