본문 바로가기
Follow Work/SpringbootBoard

[StringBoot] 로그인과 로그아웃 (20)

by ReCode.B 2022. 8. 16.
728x90

참고 링크 : https://wikidocs.net/book/7601

오라클DB와 intelliJ로 작업하였습니다


로그인 URL 등록

먼저 스프링 시큐리티에 로그인 URL을 등록하자.

SecurityConfig.java

package com.gosari.repick_project.security;

import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.security.config.annotation.web.builders.HttpSecurity;
import org.springframework.security.config.annotation.web.configuration.EnableWebSecurity;
import org.springframework.security.crypto.bcrypt.BCryptPasswordEncoder;
import org.springframework.security.crypto.password.PasswordEncoder;
import org.springframework.security.web.SecurityFilterChain;


@Configuration //스프링환경설정파일의미-스프링시큐리티의설정
@EnableWebSecurity //모든요청URL이 스프링시큐리티의 제어받음
public class SecurityConfig {

    @Bean
    public SecurityFilterChain filterChain(HttpSecurity http) throws Exception {
        /*스프링 시큐리티의 세부 설정은 SecurityFilterChain 빈을 생성하여 설정 가능*/

        /* 모든 인증되지 않은 요청을 허락한다는 의미 - 로그인하지않더라도 모든페이지에 접근가능*/
        http.authorizeRequests().antMatchers("/**").permitAll()

                /*스프링 시큐리티의 로그인 설정을 담당하는 부분*/
                .and()
                .formLogin()
                .loginPage("/user/login")
                .defaultSuccessUrl("/")
                ;

        return http.build();
    }

    //PasswordEncoder 빈(bean)
    @Bean
    public PasswordEncoder passwordEncoder() {
        return new BCryptPasswordEncoder();
    }
}

추가한 and().formLogin().loginPage("/user/login").defaultSuccessUrl("/") 

스프링 시큐리티의 로그인 설정을 담당하는 부분으로

로그인 페이지의 URL은 /user/login이고 , 로그인 성공시에 이동하는 디폴트 페이지는 루트 URL(/)임을 의미한다.

 

UserController

스프링 시큐리티에 로그인 URL을 /user/login으로 설정했으므로 User 컨트롤러에 해당 매핑을 추가해야 한다.

package com.gosari.repick_project.user;

import javax.validation.Valid;

import org.springframework.dao.DataIntegrityViolationException;
import org.springframework.stereotype.Controller;
import org.springframework.validation.BindingResult;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.PostMapping;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.dao.DataIntegrityViolationException;
import lombok.RequiredArgsConstructor;

@RequiredArgsConstructor
@Controller
@RequestMapping("/user")
public class UserController {

    private final UserService userService;

    @GetMapping("/signup")
    public String signup(UserCreateForm userCreateForm) {
        return "signup_form";
    }

    @PostMapping("/signup")
    public String signup(@Valid UserCreateForm userCreateForm, BindingResult bindingResult) {
        if (bindingResult.hasErrors()) {
            return "signup_form";
        }

        if (!userCreateForm.getPassword1().equals(userCreateForm.getPassword2())) {
            bindingResult.rejectValue("password2", "passwordInCorrect",
                    "2개의 패스워드가 일치하지 않습니다.");
            return "signup_form";
        }

        /*중복회원가입막기*/
        try {
            userService.create(userCreateForm.getUsername(),
                    userCreateForm.getEmail(), userCreateForm.getPassword1());
        }catch(DataIntegrityViolationException e) {
            e.printStackTrace();
            bindingResult.reject("signupFailed", "이미 등록된 사용자입니다.");
            return "signup_form";
        }catch(Exception e) {
            e.printStackTrace();
            bindingResult.reject("signupFailed", e.getMessage());
            return "signup_form";
        }

        return "redirect:/";
    }

    /*login_form.html 템플릿을 렌더링하는 GET방식의 login 메서드추가*/
    @GetMapping("/login")
    public String login(){
        return "login_form";
    }
}

ogin_form.html 템플릿을 렌더링하는 GET 방식의 login 메서드를 추가했다.

실제 로그인을 진행하는 @PostMapping 방식의 메서드는 스프링 시큐리티가 대신 처리하므로 직접 구현할 필요가 없다.

 

 

login_form.html

로그인 템플릿은 다음과 같이 작성하자.

<html xmlns:th="http://www.thymeleaf.org" xmlns:layout="http://www.w3.org/1999/xhtml"
      layout:decorate="~{layout}">

<div layout:fragment="content" class="container my-3">
    <form action="@{/user/login}" method="post">

        <div th:if="${param.error}">
        <div class="alert alert-danger">
            사용자ID 또는 비밀번호를 확인해주세요
        </div>
        </div>

        <div class="mb-3">
            <label for="username" class="form-label">사용자ID</label>
            <input type="text" name="username" id="username" class="form-control">
        </div>

        <div class="mb-3">
            <label for="password" class="form-label">비밀번호</label>
            <input type="password" name="password" id="password" class="form-control">
        </div>

        <button type="submit" class="btn btn-primary">로그인</button>
    </form>
</div>

</html>

사용자ID와 비밀번호로 로그인을 할 수 있는 로그인 템플릿을 작성했다. 시큐리티의 로그인이 실패할 경우에는 로그인 페이지로 다시 리다이렉트 된다. 이 때 페이지 파라미터로 error가 함께 전달된다. 따라서 로그인 페이지의 파라미터로 error가 전달될 경우 "사용자ID 또는 비밀번호를 확인해 주세요." 라는 오류메시지를 출력하도록 했다.

로그인 실패시 파라미터로 error가 전달되는 것은 스프링 시큐리티의 규칙이다.

여기까지 수정하고 브라우저에서 http://localhost:8080/user/login을 호출해 보자. 그러면 다음과 같은 화면이 나타난다.

하지만 아직 로그인을 수행할 수는 없다.

왜냐하면 스프링 시큐리티에 무엇을 기준으로 로그인을 해야 하는지 아직 설정하지 않았기 때문이다.

스프링 시큐리티를 통해 로그인을 수행하는 방법에는 여러가지가 있다.

예를 들어 가장 간단하게는 시큐리티 설정 파일에 직접 아이디, 비밀번호를 등록하여 인증을 처리하는 메모리 방식이 있다. 하지만 우리는 이미 이전 장에서 회원가입을 통해 회원 정보를 데이터베이스에 저장했으므로

데이터베이스에서 회원정보를 조회하는 방법을 사용해야 할 것이다.

이제 데이터베이스에서 사용자를 조회하는 서비스(UserSecurityService)를 만들고

그 서비스를 스프링 시큐리티에 등록하는 방법에 대해서 알아보자.

하지만 UserSecurityService를 만들고 등록하기 전에 UserSecurityService에서 필요한

UserRepository, UserRole 등을 먼저 준비해야 한다.

 

UserRepository

앞으로 작성할 UserSecurityService는 사용자를 조회하는 기능이 필요하므로 다음처럼 findByUsername 메서드를 User 리포지터리에 추가하자.

package com.gosari.repick_project.user;

import org.springframework.data.jpa.repository.JpaRepository;

import java.util.Optional;

public interface UserRepository extends JpaRepository<SiteUser, Long> {
    Optional<SiteUser> findByusername(String username); //사용자를 조회하는 기능 필요
}

 

UserRole

스프링 시큐리티는 인증 뿐만 아니라 권한도 관리한다. 따라서 인증후에 사용자에게 부여할 권한이 필요하다.

다음과 같이 ADMIN, USER 2개의 권한을 갖는 UserRole을 신규로 작성하자.

package com.gosari.repick_project.user;

import lombok.Getter;

@Getter /*enum 열거자료형 , 상수자료형이므로 Getter만 사용가능*/
public enum UserRole {
    ADMIN("ROLE_ADMIN"),
    USER("ROLE_USER");

    UserRole(String value) {
        this.value = value;
    }

    private String value;
}

UserRole은 열거 자료형(enum)으로 작성했다.

ADMIN은 "ROLE_ADMIN", USER는 "ROLE_USER" 라는 값을 가지도록 했다.

그리고 상수 자료형이므로 @Setter없이 @Getter만 사용가능하도록 했다.

이 책에서 구현할 SBB는 권한으로 특정 기능을 제어하지 않는다. 하지만 SBB 서비스를 완성하고 다른 사람이 작성한 질문이나 답변을 ADMIN 권한을 지닌 사용자는 수정이 가능하도록 만들어도 좋을 것이다.

 

UserSecurityService

그리고 스프링 시큐리티 설정에 등록할 UserSecurityService를 다음과 같이 신규로 작성하자.

package com.gosari.repick_project.user;

import java.util.ArrayList;
import java.util.List;
import java.util.Optional;

import org.springframework.security.core.GrantedAuthority;
import org.springframework.security.core.authority.SimpleGrantedAuthority;
import org.springframework.security.core.userdetails.User;
import org.springframework.security.core.userdetails.UserDetails;
import org.springframework.security.core.userdetails.UserDetailsService;
import org.springframework.security.core.userdetails.UsernameNotFoundException;
import org.springframework.stereotype.Service;

import lombok.RequiredArgsConstructor;

@RequiredArgsConstructor
@Service                /*스프링시큐리티가 제공하는 UserDetailService*/
public class UserSecurityService implements UserDetailsService {

    private final UserRepository userRepository;

    @Override /*loadUserByUsername : 사용자명으로 비밀번호를 조회하여 리턴하는 메서드*/
    public UserDetails loadUserByUsername(String username) throws UsernameNotFoundException {
        Optional<SiteUser> _siteUser = this.userRepository.findByusername(username);
        if (_siteUser.isEmpty()) {
            throw new UsernameNotFoundException("사용자를 찾을수 없습니다.");
        }
        SiteUser siteUser = _siteUser.get();
        List<GrantedAuthority> authorities = new ArrayList<>();
        if ("admin".equals(username)) {
            authorities.add(new SimpleGrantedAuthority(UserRole.ADMIN.getValue()));
        } else {
            authorities.add(new SimpleGrantedAuthority(UserRole.USER.getValue()));
        }
        return new User(siteUser.getUsername(), siteUser.getPassword(), authorities);
    }
}

스프링 시큐리티에 등록하여 사용할 UserSecurityService는 스프링 시큐리티가 제공하는 UserDetailsService 인터페이스를 구현(implements)해야 한다.

스프링 시큐리티의 UserDetailsService는 loadUserByUsername 메서드를 구현하도록 강제하는 인터페이스이다. loadUserByUsername 메서드는 사용자명으로 비밀번호를 조회하여 리턴하는 메서드이다.

UserSecurityService는 스프링 시큐리티 로그인 처리의 핵심 부분이다.

조금 더 자세히 살펴보면, loadUserByUsername 메서드는 사용자명으로 SiteUser 객체를 조회하고 만약 사용자명에 해당하는 데이터가 없을 경우에는 UsernameNotFoundException 오류를 내게 했다. 그리고 사용자명이 "admin"인 경우에는 ADMIN 권한을 부여하고 그 이외의 경우에는 USER 권한을 부여했다. 그리고 사용자명, 비밀번호, 권한을 입력으로 스프링 시큐리티의 User 객체를 생성하여 리턴했다. 스프링 시큐리티는 loadUserByUsername 메서드에 의해 리턴된 User 객체의 비밀번호가 화면으로부터 입력 받은 비밀번호와 일치하는지를 검사하는 로직을 내부적으로 가지고 있다.

 

 

SecurityConfig

그리고 다음처럼 스프링 시큐리티에 UserSecurityService를 등록하자.

SecurityConfig.java

package com.gosari.repick_project.security;

import com.gosari.repick_project.user.UserSecurityService;

import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.security.authentication.AuthenticationManager;
import org.springframework.security.config.annotation.authentication.configuration.AuthenticationConfiguration;
import org.springframework.security.config.annotation.web.builders.HttpSecurity;
import org.springframework.security.config.annotation.web.configuration.EnableWebSecurity;
import org.springframework.security.web.SecurityFilterChain;
import org.springframework.security.crypto.bcrypt.BCryptPasswordEncoder;
import org.springframework.security.crypto.password.PasswordEncoder;
import org.springframework.security.web.header.writers.frameoptions.XFrameOptionsHeaderWriter;
import lombok.RequiredArgsConstructor;

@RequiredArgsConstructor
@Configuration //스프링환경설정파일의미-스프링시큐리티의설정
@EnableWebSecurity //모든요청URL이 스프링시큐리티의 제어받음
public class SecurityConfig {

    private final UserSecurityService userSecurityService;

    @Bean
    public SecurityFilterChain filterChain(HttpSecurity http) throws Exception {
        /*스프링 시큐리티의 세부 설정은 SecurityFilterChain 빈을 생성하여 설정 가능*/

        /* 모든 인증되지 않은 요청을 허락한다는 의미 - 로그인하지않더라도 모든페이지에 접근가능*/
        http.authorizeRequests().antMatchers("/**").permitAll()

                /*스프링 시큐리티의 로그인 설정을 담당하는 부분*/
                .and()
                .formLogin()
                .loginPage("/user/login")
                .defaultSuccessUrl("/")
        ;
        return http.build();
    }

    //PasswordEncoder 빈(bean)
    @Bean
    public PasswordEncoder passwordEncoder() {
        return new BCryptPasswordEncoder();
    }

    /*AuthenticationManager 스프링 시큐리티의 인증을 담당*/
    @Bean
    public AuthenticationManager authenticationManager(AuthenticationConfiguration authenticationConfiguration) throws Exception {
        return authenticationConfiguration.getAuthenticationManager();
    }
}

AuthenticationManager 빈을 생성했다.

AuthenticationManager는 스프링 시큐리티의 인증을 담당한다.

AuthenticationManager 빈 생성시 스프링의 내부 동작으로 인해

위에서 작성한 UserSecurityService와 PasswordEncoder가 자동으로 설정된다.

 

그리고 마지막으로 로그인 페이지에 진입할수 있는 로그인 링크를 네비게이션바에 다음과 같이 추가하자.

<nav th:fragment="navbarFragment" class="navbar navbar-expand-lg navbar-light bg-light border-bottom">
    <div class="container-fluid">
        <a class="navbar-brand" href="/">SBB</a>
        <button class="navbar-toggler" type="button" data-bs-toggle="collapse" data-bs-target="#navbarSupportedContent"
                aria-controls="navbarSupportedContent" aria-expanded="false" aria-label="Toggle navigation">
            <span class="navbar-toggler-icon"></span>
        </button>
        <div class="collapse navbar-collapse" id="navbarSupportedContent">
            <ul class="navbar-nav me-auto mb-2 mb-lg-0">
                <li class="nav-item">
                    <a class="nav-link" th:href="@{/user/login}">로그인</a>
                </li>
                <!--회원가입화면으로 이동할수있는 링크-->
                <li class="nav-item">
                    <a class="nav-link" th:href="@{/user/signup}">회원가입</a>
                </li>

            </ul>
        </div>
    </div>
</nav>
<a class="nav-link" th:href="@{/user/login}">로그인</a>

이제 내비게이션 바의 "로그인" 링크를 누르면 /user/login으로 이동하며, 다음과 같은 로그인 화면이 나타날 것이다.

만약 데이터베이스에 없는 username 또는 잘못된 password를 입력하면 다음처럼 오류 메시지가 나타난다.

username과 password를 제대로 입력하면 로그인이 정상 수행되고 메인 화면으로 이동할 것이다.

축하한다! 로그인에 성공했다.

 

로그인 / 로그아웃 링크

하지만 로그인한 후에도 내비게이션 바에는 여전히 "로그인" 링크가 남아 있다.

로그인을 한 상태라면 이 링크는 "로그아웃" 링크로 바뀌어야 한다.

반대로 로그아웃 상태에서는 "로그인" 링크로 바뀌어야 한다.

 

사용자의 로그인 여부는 타임리프의 sec:authorize 속성을 통해 알수 있다.

  • sec:authorize="isAnonymous()" - 이 속성은 로그인 되지 않은 경우에만 해당 엘리먼트가 표시되게 한다.
  • sec:authorize="isAuthenticated()" - 이 속성은 로그인 된 경우에만 해당 엘리먼트가 표시되게 한다.

따라서 다음과 같이 내비게이션바를 수정할수 있다.

<nav th:fragment="navbarFragment" class="navbar navbar-expand-lg navbar-light bg-light border-bottom">
    <div class="container-fluid">
        <a class="navbar-brand" href="/">SBB</a>
        <button class="navbar-toggler" type="button" data-bs-toggle="collapse" data-bs-target="#navbarSupportedContent"
                aria-controls="navbarSupportedContent" aria-expanded="false" aria-label="Toggle navigation">
            <span class="navbar-toggler-icon"></span>
        </button>
        <div class="collapse navbar-collapse" id="navbarSupportedContent">
            <ul class="navbar-nav me-auto mb-2 mb-lg-0">
                <li class="nav-item">
                    <a class="nav-link" sec:authorize="isAnonymous()" th:href="@{/user/login}">로그인</a>
                    <a class="nav-link" sec:authorize="isAuthenticated()" th:href="@{/user/logout}">로그아웃</a>
                </li>
                <!--회원가입화면으로 이동할수있는 링크-->
                <li class="nav-item">
                    <a class="nav-link" th:href="@{/user/signup}">회원가입</a>
                </li>

            </ul>
        </div>
    </div>
</nav>
<a class="nav-link" sec:authorize="isAnonymous()" th:href="@{/user/login}">로그인</a>
<a class="nav-link" sec:authorize="isAuthenticated()" th:href="@{/user/logout}">로그아웃</a>

로그인을 안한 상태라면 sec:authorize="isAnonymous()"가 참이되어 "로그인" 링크가 표시되고

로그인을 한 상태라면 sec:authorize="isAuthenticated()"가 참이되어 "로그아웃" 링크가 표시될 것이다.

이제 로그인을 정상적으로 수행하면 다음처럼 "로그아웃" 링크가 표시되는 것을 확인할 수 있다.

로그아웃 링크는 /user/logout으로 지정했다. 하지만 로그아웃 기능은 아직 구현하지 않은 상태이다. 로그아웃 기능은 바로 이어서 진행한다.

 

로그아웃 구현하기

로그인을 통해 로그인을 했다면 내비게이션 상단에 "로그아웃" 링크가 나타날 것이다. 하지만 "로그아웃" 링크를 누르면 다음과 같이 404 오류 페이지가 표시된다.

아직 로그아웃 기능을 구현하지 않았기 때문이다. 로그아웃 역시 스프링 시큐리티를 사용하여 쉽게 구현할수 있다.


SecurityConfig

다음과 같이 SecurityConfig 파일을 수정하자.

package com.gosari.repick_project.security;

import com.gosari.repick_project.user.UserSecurityService;

import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.security.authentication.AuthenticationManager;
import org.springframework.security.config.annotation.authentication.configuration.AuthenticationConfiguration;
import org.springframework.security.config.annotation.web.builders.HttpSecurity;
import org.springframework.security.config.annotation.web.configuration.EnableWebSecurity;
import org.springframework.security.web.SecurityFilterChain;
import org.springframework.security.crypto.bcrypt.BCryptPasswordEncoder;
import org.springframework.security.crypto.password.PasswordEncoder;
import org.springframework.security.web.header.writers.frameoptions.XFrameOptionsHeaderWriter;
import lombok.RequiredArgsConstructor;
import org.springframework.security.web.util.matcher.AntPathRequestMatcher;

@RequiredArgsConstructor
@Configuration //스프링환경설정파일의미-스프링시큐리티의설정
@EnableWebSecurity //모든요청URL이 스프링시큐리티의 제어받음
public class SecurityConfig {

    private final UserSecurityService userSecurityService;

    @Bean
    public SecurityFilterChain filterChain(HttpSecurity http) throws Exception {
        /*스프링 시큐리티의 세부 설정은 SecurityFilterChain 빈을 생성하여 설정 가능*/

        /* 모든 인증되지 않은 요청을 허락한다는 의미 - 로그인하지않더라도 모든페이지에 접근가능*/
        http.authorizeRequests().antMatchers("/**").permitAll()

                /*스프링 시큐리티의 로그인 설정을 담당하는 부분*/
                .and()
                .formLogin()
                .loginPage("/user/login")
                .defaultSuccessUrl("/")
                
                /*로그아웃을 위한 설정 추가*/
                .and()
                .logout()
                .logoutRequestMatcher(new AntPathRequestMatcher("/user/logout"))
                .logoutSuccessUrl("/")
                .invalidateHttpSession(true)
        ;
        return http.build();
    }

    //PasswordEncoder 빈(bean)
    @Bean
    public PasswordEncoder passwordEncoder() {
        return new BCryptPasswordEncoder();
    }

    /*AuthenticationManager 스프링 시큐리티의 인증을 담당*/
    @Bean
    public AuthenticationManager authenticationManager(AuthenticationConfiguration authenticationConfiguration) throws Exception {
        return authenticationConfiguration.getAuthenticationManager();
    }
}

로그아웃을 위한 설정을 추가했다. 로그아웃 URL을 /user/logout으로 설정하고 로그아웃이 성공하면 루트(/) 페이지로 이동하도록 했다. 그리고 로그아웃시 생성된 사용자 세션도 삭제하도록 처리했다.

수정을 완료한 후 로그인, 로그아웃 기능이 잘 실행되는지 확인해 보자.

로그아웃을 누르면 네비게이션 바에는 다시 "로그인" 링크가 나타날 것이다.

728x90