[스프링부트 시리즈26] 로그인과 로그아웃 기능 구현하기

Featured image

로그인과 로그아웃 기능 구현하기


회원 가입 기능을 완성했으니 이번에는 로그인과 로그아웃 기능을 구현해 보자. SBB는 여러 사람이 사용하는 게시판 서비스다. 그러므로 질문한 사람, 답변한 사람을 구별하는 목적으로 로그인은 필수 기능이다. 앞서 여러분은 회원 가입 기능을 구현했으므로 로그인, 로그아웃 기능도 쉽게 구현할 수 있을 것이다.

로그인 기능 구현하기


회원 가입 단계에서 SITE_USER 테이블에 회원 정보를 저장했다. SITE_USER 테이블에 저장된 사용자명(사용자 ID)과 비밀번호로 로그인을 하려면 복잡한 단계를 거쳐야 한다. 하지만 스프링 시큐리티(Spring Security)를 사용하면 이 단계를 보다 쉽게 진행할 수 있다.

순서대로 실습 내용을 따라 하며 로그인 기능을 구현해 보자.

로그인 URL 등록하기

먼저 스프링 시큐리티에 로그인을 하기 위한 URL을 다음과 같이 설정하자.

[파일명:SecurityConfig.java]

(... 생략 ...)

@Configuration
@EnableWebSecurity
public class SecurityConfig {
    @Bean
    SecurityFilterChain filterChain(HttpSecurity http) throws Exception {
        http
            .authorizeHttpRequests((authorizeHttpRequests) -> authorizeHttpRequests
                .requestMatchers(new AntPathRequestMatcher("/**")).permitAll())
            .csrf((csrf) -> csrf
                .ignoringRequestMatchers(new AntPathRequestMatcher("/h2-console/**")))
            .headers((headers) -> headers
                .addHeaderWriter(new XFrameOptionsHeaderWriter(
                    XFrameOptionsHeaderWriter.XFrameOptionsMode.SAMEORIGIN)))
            .formLogin((formLogin) -> formLogin
                .loginPage("/user/login")
                .defaultSuccessUrl("/"))
        ;
        return http.build();
    }

    @Bean
    PasswordEncoder passwordEncoder() {
        return new BCryptPasswordEncoder();
    }
}

여기서 추가한 .formLogin 메서드는 스프링 시큐리티의 로그인 설정을 담당하는 부분으로, 설정 내용은 로그인 페이지의 URL은 /user/login이고 로그인 성공 시에 이동할 페이지는 루트 URL(/)임을 의미한다.

User 컨트롤러에 URL 매핑 추가하기

스프링 시큐리티에 로그인 URL을 /user/login으로 설정했으므로 UserController에 해당 URL을 매핑해야 한다. 다음과 같이 코드를 추가해 보자.

[파일명:/user/UserController.java]

(... 생략 ...)
public class UserController {

    (... 생략 ...)

    @GetMapping("/login")
    public String login() {
        return "login_form";
    }
}

@GetMapping("/login")을 통해 /user/login URL로 들어오는 GET 요청을 이 메서드가 처리한다. 즉, /user/login URL을 매핑했다. 그리고 매핑한 login 메서드는 login_form.html 템플릿을 출력하도록 만든다. 실제 로그인을 진행하는 @PostMapping 방식의 메서드는 스프링 시큐리티가 대신 처리하므로 우리가 직접 코드를 작성하여 구현할 필요가 없다.

로그인 템플릿 작성하기

1) 로그인 화면을 구성하는 템플릿을 만들어 보자.

[파일명:/templates/login_form.html]

<html layout:decorate="~{layout}">
<div layout:fragment="content" class="container my-3">
    <form th: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?error와 같이 error 매개변수를 전달한다. 이때 템플릿에서 ${param.error}로 error 매개변수가 전달되었는지 확인할 수 있다.

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

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

스프링 시큐리티를 통해 로그인을 수행하는 방법에는 여러 가지가 있는데, 그중에서 가장 간단한 방법으로 SecurityConfig.java와 같은 시큐리티 설정 파일에 사용자 ID와 비밀번호를 직접 등록하여 인증을 처리하는 메모리 방식이 있다. 하지만 우리는 이미 3-06절에서 회원 가입을 통해 회원 정보를 DB에 저장했으므로 DB에서 회원 정보를 조회하여 로그인하는 방법을 사용할 것이다.

이제 DB에서 사용자를 조회하는 서비스(UserSecurityService.java)를 만들고, 그 서비스를 스프링 시큐리티에 등록하는 방법을 알아보자. 하지만 UserSecurityService 서비스를 만들기 전에 UserRepository를 수정하고 UserRole 클래스를 생성하는 등 준비를 해야 한다. 서비스를 활용하기 위한 밑 작업을 진행해 보자.

User 리포지터리 수정하기

뒤에서 생성할 UserSecurityService는 사용자 ID를 조회하는 기능이 필요하므로 다음과 같이 사용자 ID로 SiteUser 엔티티를 조회하는 findByUsername 메서드를 유저리포지터리에 추가하자.

[파일명:/user/UserRepository.java]

package com.mysite.sbb.user;

import java.util.Optional;

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

public interface UserRepository extends JpaRepository<SiteUser, Long> {
    Optional<SiteUser> findByusername(String username);
}

UserRole 파일 생성하기

3-05절에서 언급했지만 스프링 시큐리티는 인증뿐만 아니라 권한도 관리한다. 스프링 시큐리티는 사용자 인증 후에 사용자에게 부여할 권한과 관련된 내용이 필요하다. 그러므로 우리는 사용자가 로그인한 후, ADMIN 또는 USER와 같은 권한을 부여해야 한다.

여기서는 Enum 클래스를 사용하므로, com.mysite.sbb.user 패키지를 선택한 후 마우스 오른쪽 버튼을 누르고 [New → Enum]을 클릭해도 좋다.

다음과 같이 com.mysite.sbb.user 패키지에 UserRole.java 파일을 만들어 보자.

[파일명:/user/UserRole.java]

package com.mysite.sbb.user;

import lombok.Getter;


@Getter
public enum UserRole {
    ADMIN("ROLE_ADMIN"),
    USER("ROLE_USER");

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

    private String value;
}

UserRole은 enum 자료형(열거 자료형)으로 작성했다. 관리자를 의미하는 ADMIN과 사용자를 의미하는 USER라는 상수를 만들었다. 그리고 ADMIN은 ‘ROLE_ADMIN’, USER는 ‘ROLE_USER’라는 값을 부여했다. 그리고 UserRoleADMINUSER 상수는 값을 변경할 필요가 없으므로 @Setter 없이 @Getter만 사용할 수 있도록 했다.

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

UserSecurityService 서비스 생성하기

com.mysite.sbb.user 패키지에 스프링 시큐리티가 로그인 시 사용할 서비스인 UserSecurityService.java를 다음과 같이 작성해 보자.

[파일명:/user/UserSecurityService.java]

package com.mysite.sbb.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
public class UserSecurityService implements UserDetailsService {

    private final UserRepository userRepository;

    @Override
    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)해야 한다. 스프링 시큐리티의 UserDetailsServiceloadUserByUsername 메서드를 구현하도록 강제하는 인터페이스이다. loadUserByUsername 메서드는 사용자명(username)으로 스프링 시큐리티의 사용자(User) 객체를 조회하여 리턴하는 메서드이다.

조금 더 자세히 살펴보면, loadUserByUsername 메서드는 사용자명으로 SiteUser 객체를 조회하고, 만약 사용자명에 해당하는 데이터가 없을 경우에는 UsernameNotFoundException을 발생시킨다. 그리고 사용자명이 ‘admin’인 경우에는 ADMIN 권한(ROLE_ADMIN)을 부여하고 그 이외의 경우에는 USER 권한(ROLE_USER)을 부여했다. 마지막으로 User 객체를 생성해 반환하는데, 이 객체는 스프링 시큐리티에서 사용하며 User 생성자에는 사용자명, 비밀번호, 권한 리스트가 전달된다.

참고로, 스프링 시큐리티는 loadUserByUsername 메서드에 의해 리턴된 User 객체의 비밀번호가 사용자로부터 입력받은 비밀번호와 일치하는지를 검사하는 기능을 내부에 가지고 있다.

스프링 시큐리트 설정 수정하기

로그인 기능을 완성하기 위해 SecurityConfig.java 파일을 열어 다음과 같이 AuthenticationManager 빈을 생성하자.

[파일명:SecurityConfig.java]

(... 생략 ...)
import org.springframework.security.authentication.AuthenticationManager;
import org.springframework.security.config.annotation.authentication.configuration.AuthenticationConfiguration;
(... 생략 ...)

@Configuration
@EnableWebSecurity
public class SecurityConfig {

    (... 생략 ...)

    @Bean
    AuthenticationManager authenticationManager(AuthenticationConfiguration authenticationConfiguration) throws Exception {
        return authenticationConfiguration.getAuthenticationManager();
    }
}

이와 같이 AuthenticationManager 빈을 생성했다. AuthenticationManager는 스프링 시큐리티의 인증을 처리한다. AuthenticationManager는 사용자 인증 시 앞에서 작성한 UserSecurityServicePasswordEncoder를 내부적으로 사용하여 인증과 권한 부여 프로세스를 처리한다.

로그인화면 수정하기

1) 로그인 기능을 구현하는 마지막 단계로 로그인 페이지에 곧바로 진입할 수 있도록 로그인 링크(/user/login)를 내비게이션 바에 추가해 보자. 방법은 아주 간단하다. templatesnavbar.html을 다음과 같이 수정하면 된다.

[파일명:/templates/navbar.html]

<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>

2) 로그인 기능을 구현하는 모든 작업을 마쳤으니 로컬 서버를 재시작한 후, 브라우저를 통해 http://local:8080에 접속하고 내비게이션 바의 ‘로그인’ 링크를 클릭해 보자. 그러면 다음과 같은 로그인 화면이 등장한다. 로그인접속

3) 만약 DB에 없는 사용자 ID 또는 잘못된 비밀번호를 입력하면 다음과 같이 오류 메시지가 나타난다. 오류

이미 가입되어 있는 사용자 ID와 비밀번호를 입력하면 로그인이 정상 수행되고 메인 화면인 질문 목록 페이지로 이동할 것이다.

4) 하지만 로그인한 후에도 내비게이션 바에는 여전히 ‘로그인’이란 이름으로 링크가 표시된다. 일반적으로 로그인한 상태라면 이 링크는 로그아웃을 위해 ‘로그아웃’ 링크로 바뀌어야 한다(반대로 로그아웃 상태에서는 ‘로그인’ 링크로 바뀌어야 한다.)

그러기 위해 다음과 같은 스프링 시큐리티의 타임리프(Tymeleaf) 확장 기능을 사용하여 사용자의 로그인 상태를 확인해야 한다.

  • sec:authorize="isAnonymous()": 로그인되지 않은 경우에 해당 요소(로그인 링크)가 표시된다.
  • sec:authorize="isAuthenticated()": 로그인된 경우에 해당 요소(로그아웃 링크)가 표시된다.

여기서 sec:authorize 속성은 사용자의 로그인 여부에 따라 요소를 출력하거나 출력하지 않게 한다. 이 내용을 활용하여 앞에서 로그인 링크를 수정한 부분을 다시 다음과 같이 수정해 보자.

[파일명:/templates/navbar.html]

<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>

로그인하지 않은 상태라면 sec:authorize="isAnonymous()"가 ‘참’이 되어 ‘로그인’ 링크가 표시되고. 로그인한 상태라면 sec:authorize="isAuthenticated()"가 ‘참’이 되어 ‘로그아웃’ 링크가 표시될 것이다.

5) 다시 브라우저에 접속하여 이제 로그인을 수행하면 다음과 같이 ‘로그아웃’ 링크가 표시되는 것을 확인할 수 있다. 로그아웃

로그아웃 기능 구현하기


1) navbar.html에서 우리는 ‘로그아웃’ 링크를 /user/logout으로 설정했다. 하지만 아직 로그아웃 기능은 구현하지 않은 상태이다. 앞선 실습에서 SBB에 로그인을 했다면 내비게이션 바에 ‘로그아웃’ 링크가 나타난 것을 확인할 수 있다. 그런데 ‘로그아웃’ 링크를 누르면 다음과 같이 404 오류 페이지가 표시된다. 404

아직 로그아웃 기능을 구현하지 않아서 이와 같이 오류 페이지가 표시된다. 로그아웃 역시 스프링 시큐리티(Spring Security)를 사용하여 쉽게 구현할 수 있다.

2) 로그아웃 설정을 추가하기 위해 다음과 같이 SecurityConfig 파일을 수정해 보자.

[파일명: SecurityConfig.java]

(... 생략 ...)

@Configuration
@EnableWebSecurity
public class SecurityConfig  {

    @Bean
    SecurityFilterChain filterChain(HttpSecurity http) throws Exception {
        http
            .authorizeHttpRequests((authorizeHttpRequests) -> authorizeHttpRequests
                .requestMatchers(new AntPathRequestMatcher("/**")).permitAll())
            .csrf((csrf) -> csrf
                .ignoringRequestMatchers(new AntPathRequestMatcher("/h2-console/**")))
            .headers((headers) -> headers
                .addHeaderWriter(new XFrameOptionsHeaderWriter(
                    XFrameOptionsHeaderWriter.XFrameOptionsMode.SAMEORIGIN)))
            .formLogin((formLogin) -> formLogin
                .loginPage("/user/login")
                .defaultSuccessUrl("/"))
            .logout((logout) -> logout
                .logoutRequestMatcher(new AntPathRequestMatcher("/user/logout"))
                .logoutSuccessUrl("/")
                .invalidateHttpSession(true))
        ;
        return http.build();
    }

    (... 생략 ...)
}

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

3) 수정을 완료한 후 다시 http://localhost:8080에서 로그인한 후, ‘로그아웃’ 링크를 클릭하여 다시 ‘로그인’ 링크가 등장하는지 확인해 보자.