본문 바로가기
Spring boot/스프링 부트 3 백엔드 개발자 되기(자바편)

[8장] 스프링 시큐리티로 로그인/로그아웃, 회원 가입 구현하기

by 리버🐦‍🔥 2023. 7. 25.

8.1 사전 지식 : 스프링 시큐리티

    - 인증 : 사용자의 신원을 입증하는 과정

    - 인가 : 사이트의 특정 부분에 접근할 수 있는지에 대한 권한을 확인하는 작업 (관리자와 사용자 페이지)

    - 스프링 시큐리티 : 스프링 기반의 애플리케이션의 보안을 담당하는 스프링 하위 프레임워크(필터 기반 동작)

더보기

<스프링 시큐리티의 다양한 필터중 눈 여겨볼 필터>

- UsernamePasswordAuthenticationFilter : 아이디와 패스워드가 넘어오면 인증 요청을 위임하는 인증 관리자 역할

- FilterSecurityInterceptor : 권한 부여 처리를 위임해 접근 제어 결정을 쉽게 하는 접근 결정 관리자 역할

그 이외의 필터들은 직접 사용하면서 공부하기...

Spring Security 동작 흐름

8.2 회원 도메인 만들기

    8.2.1 의존성 추가하기

        1단계. build.gradle 수정

plugins {
    id 'java'
    id 'org.springframework.boot' version '3.0.2'   // 스프링 부트 플러그인
    id 'io.spring.dependency-management' version '1.1.0'    // 스프링 의존성 자동 관리 플러그인
}

group 'me.kyungsoolee'  // 그룹 이름
version '1.0'   // 버전
sourceCompatibility = '17'  // 자바 버전

repositories {  // 의존성을 받을 저장소
    mavenCentral()
}

dependencies {
    implementation 'org.springframework.boot:spring-boot-starter-web'   // 웹 관련 기능 제공
    // 스프링 데이터 JPA
    implementation 'org.springframework.boot:spring-boot-starter-data-jpa'
    runtimeOnly 'com.h2database:h2' // 인메모리 데이터베이스
    compileOnly 'org.projectlombok:lombok'  // 롬복
    annotationProcessor 'org.projectlombok:lombok'
    testImplementation 'org.springframework.boot:spring-boot-starter-test'  // 테스트 기능 제공
    // 스프링 시큐리티를 사용하기 위한 스타터 추가
    implementation 'org.springframework.boot:spring-boot-starter-security'
    // 타임리프에서 스프링 시큐리티를 사용하기 위한 의존성 추가
    implementation 'org.thymeleaf.extras:thymeleaf-extras-springsecurity6'
    // 스프링 시큐리티를 테스트하기 위한 의존성 추가
    testImplementation 'org.springframework.security:spring-security-test'
}

test {
    useJUnitPlatform()
}

    8.2.2 엔티티 만들기

        1단계. User 클래스 생성

package me.kyungsoolee.springbootdeveloperblog.domain;

import jakarta.persistence.*;
import lombok.Builder;
import lombok.Getter;
import lombok.NoArgsConstructor;
import org.springframework.security.core.GrantedAuthority;
import org.springframework.security.core.authority.SimpleGrantedAuthority;
import org.springframework.security.core.userdetails.UserDetails;

import java.util.Collection;
import java.util.List;

@Table(name = "users")
@NoArgsConstructor
@Getter
@Entity
public class User implements UserDetails {  // UserDetails를 상속받아 인증 객체로 사용

    @Id
    @GeneratedValue
    @Column(name = "id", updatable = false)
    private Long id;

    @Column(name = "email", nullable = false, unique = true)
    private String email;

    @Column(name = "password")
    private String password;

    @Builder
    public User(String email, String password, String auth) {
        this.email = email;
        this.password = password;
    }

    @Override // 권한 반환
    public Collection<? extends GrantedAuthority> getAuthorities() {
        return List.of(new SimpleGrantedAuthority("user"));
    }

    // 사용자의 패스워드를 반환
    @Override
    public String getUsername() {
        return password;
    }

    // 계정 만료 여부 반환
    @Override
    public boolean isAccountNonExpired() {
        // 만료되었는지 확인하는 로직
        return true; // true -> 만료되지 않았음
    }

    // 계정 잠금 여부 반환
    @Override
    public boolean isAccountNonLocked() {
        // 계정 잠금되었는지 확인하는 로직
        return true; // true -> 잠금되지 않았음
    }

    // 팩스워드 만료 여부 반환
    @Override
    public boolean isCredentialsNonExpired() {
        // 패스워드가 만료되었는지 확인하는 로직
        return true; // true -> 만료되지 않았음
    }

    // 계정 사용 가능 여부 반환
    @Override
    public boolean isEnabled() {
        // 계정이 사용 가능한지 확인하는 로직
        return true; // true -> 사용 가능
    }
}
메서드 반환 타입 설명
getAuthorities() Collection<? extends GrantedAuthority> 사용자가 가지고 있는 권한의 목록을 반환합니다. 현재 예제 코드에서는 사용자 이외의 권한이 없기 때문에 user 권한만 담아 반환합니다.
getUsername() String 사용자를 식별할 수 있는 사용자 일므을 반환합니다. 이때 사용되는 사용자 이름은 반드시 고유해야 합니다. 현재 예제 코드는 유니크 속성이 적용된 이메일을 반환합니다.
getPassword() String 사용자의 비밀번호를 반환합니다. 이때 저장되어 있는 비밀번호는 암호화해서 저장해야 합니다.
isAccountNonExpired() boolean 계정이 만료되었는지 확인하는 메서드입니다. 만약 만료되지 않은 때는 true를 반환합니다.
isAccountNonLocked() boolean 계정이 잠금되었는지 확인하는 메서드입니다. 만약 잠금되지 않은 때는 true를 반환합니다.
isCredentialsNonExpired() boolean 비밀번호가 만료되었는지 확인하는 메서드입니다. 만약 만료되지 않은 때는 true를 반환합니다.
isEnabled() boolean 계정이 사용 가능한지 확인하는 메서드입니다. 만약 사용 가능하다면 true를 반환합니다.

    8.2.3 리포지터리 만들기

        1단계. UserRepository 구현

package me.kyungsoolee.springbootdeveloperblog.repository;


import me.kyungsoolee.springbootdeveloperblog.domain.User;
import org.springframework.data.jpa.repository.JpaRepository;

import java.util.Optional;

public interface UserRepository extends JpaRepository<User, Long> {
    Optional<User> findByEmail(String email); // email로 사용자 정보를 가져옴
}

        - findByEmail() 메서드는 실제 데이터베이스에 회원 정보를 요청할 때 아래와 같은 쿼리 실행

FROM users
WHERE email = #{email}
더보기

<자주 사용하는 쿼리 메서드의 명명규칙>

코드 설명 쿼리

findByName() "name" 컬럼의 값 중 파라미터로 들어오는 값과 같은 데이터 반환 ... WHERE name = ?1
findByNameAndAge() 파라미터로 들어오는 값 중 첫 번째 값은 "name"컬럼에서 조회하고, 두 번째 값은 "age" 컬럼에서 조회한 데이터 반환 ... WHERE name = ?1 AND age = ?2
findByNameOrAge() 파라미터로 들어오는 값 중 첫 번째 값이 "name" 컬럼에서 조회되거나 두 번째 값이 "age"에서 조회되는 데이터 반환 ... WHERE name = ?1 OR age = ?2
findbyAgeLessThan() "age" 컬럼의 값 중 파라미터로 들어온 값보다 작은 데이터 반환 ... WHERE age < ?1
findByAgeGraterThan() "age" 컬럼의 값 중 파라미터로 들어온 값보다 큰 데이터 반환 ... WHERE age > ?1
findByName(Is)Null() "name"컬럼의 값 중 null인 데이터 반환 ... WHERE name IS NULL

    8.2.4 서비스 메서드 코드 작성하기

        1단계. 스프링 시큐리티에서 로그인을 진행할 때 사용자 정보를 가져오는 메서드 작성 (UserDetailService.java)

package me.kyungsoolee.springbootdeveloperblog.service;

import lombok.RequiredArgsConstructor;
import me.kyungsoolee.springbootdeveloperblog.domain.User;
import me.kyungsoolee.springbootdeveloperblog.repository.UserRepository;
import org.springframework.security.core.userdetails.UserDetailsService;
import org.springframework.stereotype.Service;

@RequiredArgsConstructor
@Service
// 스프링 시큐리티에서 사용자 정보를 가져오는 인터페이스
public class UserDetailService implements UserDetailsService {

    private final UserRepository userRepository;

    // 사용자 이름(email)으로 사용자의 정보를 가져오는 메서드
    @Override
    public User loadUserByUsername(String email) {
        return userRepository.findByEmail(email)
                .orElseThrow(() -> new IllegalArgumentException(email));
    }
}

8.3 시큐리티 설정하기

    1단계. WebSecurityConfig.java 구현 : 실제 인증 처리를 하는 시큐리티 설정 파일

        1. 스프링 시큐리티의 모든 기능 비활성화

        2. 특정 HTTP 요청에 대한 웹 기반 보안 구성

        3. 특정 경로에 대한 액세스 설정

        4. 폼 기반 로그인 설정

        5. 로그아웃 설정

        6. CSRF 비활성화 (공격 방지를 위해서는 활성화가 좋지만, 실습을 위해 비활성화)

        7. 인증 관리자 관련 설정 : 사용자 정보 불러오기 서비스 재정의, 인증방법 설정할 때 활용

        8. 사용자 서비스 설정

        9. 패스워드 인코더를 빈으로 등록

package me.kyungsoolee.springbootdeveloperblog.config;

import lombok.RequiredArgsConstructor;
import me.kyungsoolee.springbootdeveloperblog.service.UserDetailService;
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.builders.AuthenticationManagerBuilder;
import org.springframework.security.config.annotation.web.builders.HttpSecurity;
import org.springframework.security.config.annotation.web.configuration.WebSecurityCustomizer;
import org.springframework.security.crypto.bcrypt.BCryptPasswordEncoder;
import org.springframework.security.web.SecurityFilterChain;

import static org.springframework.boot.autoconfigure.security.servlet.PathRequest.toH2Console;

@RequiredArgsConstructor
@Configuration
public class WebSecurityConfig {

    private final UserDetailService userService;

    // 스프링 시큐리티 기능 비활성화
    @Bean
    public WebSecurityCustomizer configure() {
        return (web) -> web.ignoring()
                .requestMatchers(toH2Console())
                .requestMatchers("/static/**");
    }

    // 특정 HTTP 요청에 대한 웹 기반 보안 구성
    @Bean
    public SecurityFilterChain filterChain(HttpSecurity http) throws  Exception {
        return http
                .authorizeRequests()    // 인증, 인가 설정
                .requestMatchers("/login", "/signup", "/user").permitAll()
                .anyRequest().authenticated()
                .and()
                .formLogin()    // 폼 기반 로그인 설정
                .loginPage("/login")
                .defaultSuccessUrl("/articles")
                .and()
                .logout()   // 로그아웃 설정
                .logoutSuccessUrl("/login")
                .invalidateHttpSession(true)
                .and()
                .csrf().disable()   // csrf 비활성화
                .build();
    }

    // 인증 관리자 관련 설정
    @Bean
    public AuthenticationManager authenticationManager(HttpSecurity http, BCryptPasswordEncoder bCryptPasswordEncoder, UserDetailService userDetailService) throws Exception {
        return http.getSharedObject(AuthenticationManagerBuilder.class)
                .userDetailsService(userService)
                .passwordEncoder(bCryptPasswordEncoder)
                .and()
                .build();
    }

    // 패스워드 인코더로 사용할 빈 등록
    @Bean
    public BCryptPasswordEncoder bCryptPasswordEncoder() {
        return new BCryptPasswordEncoder();
    }
}

8.4 회원 가입 구현하기

    8.4.1 서비스 메서드 구현

        1단계. AdduserRequest.java 구현

package me.kyungsoolee.springbootdeveloperblog.dto;

import lombok.Getter;
import lombok.Setter;

@Getter
@Setter
public class AddUserRequest {
    private String email;
    private String password;
}

        2단계. UserService.java 구현

package me.kyungsoolee.springbootdeveloperblog.service;

import lombok.RequiredArgsConstructor;
import me.kyungsoolee.springbootdeveloperblog.domain.User;
import me.kyungsoolee.springbootdeveloperblog.dto.AddUserRequest;
import me.kyungsoolee.springbootdeveloperblog.repository.UserRepository;
import org.springframework.security.crypto.bcrypt.BCryptPasswordEncoder;
import org.springframework.stereotype.Service;

@RequiredArgsConstructor
@Service
public class UserService {

    private final UserRepository userRepository;
    private final BCryptPasswordEncoder bCryptPasswordEncoder;

    public Long save(AddUserRequest dto) {
        return userRepository.save(User.builder()
                .email(dto.getEmail())
                // 패스워드 암호화
                .password(bCryptPasswordEncoder.encode(dto.getPassword()))
                .build()).getId();
    }
}

    8.4.2 컨트롤러 작성

        1단계. UserApiController.java 작성

package me.kyungsoolee.springbootdeveloperblog.controller;

import lombok.RequiredArgsConstructor;
import me.kyungsoolee.springbootdeveloperblog.dto.AddUserRequest;
import me.kyungsoolee.springbootdeveloperblog.service.UserService;
import org.springframework.stereotype.Controller;
import org.springframework.web.bind.annotation.PostMapping;

@RequiredArgsConstructor
@Controller
public class UserApiController {

    private final UserService userService;

    @PostMapping("/user")
    public String signup(AddUserRequest request) {
        userService.save(request);  // 회원 가입 메서드 호출
        return "redirection:/login";
    }
}

8.5 회원 가입, 로그인 뷰 작성하기

    8.5.1 뷰 컨트롤러 구현하기

        1단계. UserViewContoller 구현

package me.kyungsoolee.springbootdeveloperblog.controller;

import org.springframework.stereotype.Controller;
import org.springframework.web.bind.annotation.GetMapping;

@Controller
public class UserViewController {
    @GetMapping("/login")
    public String login() {
        return "login";
    }

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

    8.5.2 뷰 작성하기

        1단계. login.html 구현

<!DOCTYPE html>
<html xmlns:th="http://www.thymeleaf.org">
<head>
    <meta charset="UTF-8">
    <title>로그인</title>
    <link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/bootstrap@4.6.1/dist/css/bootstrap.min.css">

    <style>
        .gradient-custom {
            background: linear-gradient(to right, rgba(106, 17, 203, 1), rgba(37, 117, 252, 1))
        }
    </style>
</head>
<body class="gradient-custom">
<section class="d-flex vh-100">
    <div class="container-fluid row justify-content-center align-content-center">
        <div class="card bg-dark" style="border-radius: 1rem;">
            <div class="card-body p-5 text-center">
                <h2 class="text-white">LOGIN</h2>
                <p class="text-white-50 mt-2 mb-5">서비스를 사용하려면 로그인을 해주세요!</p>

                <div class = "mb-2">
                    <form action="/login" method="POST">
                        <input type="hidden" th:name="${_csrf?.parameterName}" th:value="${_csrf?.token}" />
                        <div class="mb-3">
                            <label class="form-label text-white">Email address</label>
                            <input type="email" class="form-control" name="username">
                        </div>
                        <div class="mb-3">
                            <label class="form-label text-white">Password</label>
                            <input type="password" class="form-control" name="password">
                        </div>
                        <button type="submit" class="btn btn-primary">Submit</button>
                    </form>

                    <button type="button" class="btn btn-secondary mt-3" onclick="location.href='/signup'">회원가입</button>
                </div>
            </div>
        </div>
    </div>
</section>
</body>
</html>

        2단계. signup.html 구현

<!DOCTYPE html>
<html xmlns:th="http://www.thymeleaf.org">
<head>
  <meta charset="UTF-8">
  <title>회원 가입</title>
  <link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/bootstrap@4.6.1/dist/css/bootstrap.min.css">

  <style>
    .gradient-custom {
      background: linear-gradient(to right, rgba(254, 238, 229, 1), rgba(229, 193, 197, 1))
    }
  </style>
</head>
<body class="gradient-custom">
<section class="d-flex vh-100">
  <div class="container-fluid row justify-content-center align-content-center">
    <div class="card bg-dark" style="border-radius: 1rem;">
      <div class="card-body p-5 text-center">
        <h2 class="text-white">SIGN UP</h2>
        <p class="text-white-50 mt-2 mb-5">서비스 사용을 위한 회원 가입</p>

        <div class = "mb-2">
          <form th:action="@{/user}" method="POST">
            <!-- 토큰을 추가하여 CSRF 공격 방지 -->
            <input type="hidden" th:name="${_csrf?.parameterName}" th:value="${_csrf?.token}" />
            <div class="mb-3">
              <label class="form-label text-white">Email address</label>
              <input type="email" class="form-control" name="email">
            </div>
            <div class="mb-3">
              <label class="form-label text-white">Password</label>
              <input type="password" class="form-control" name="password">
            </div>

            <button type="submit" class="btn btn-primary">Submit</button>
          </form>
        </div>
      </div>
    </div>
  </div>
</section>
</body>
</html>

    8.6.1 로그아웃 메서드 추가하기

        1단계. UserApiController에 logout 기능 추가

package me.kyungsoolee.springbootdeveloperblog.controller;

import jakarta.servlet.http.HttpServletRequest;
import jakarta.servlet.http.HttpServletResponse;
import lombok.RequiredArgsConstructor;
import me.kyungsoolee.springbootdeveloperblog.dto.AddUserRequest;
import me.kyungsoolee.springbootdeveloperblog.service.UserService;
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 org.springframework.web.bind.annotation.PostMapping;

@RequiredArgsConstructor
@Controller
public class UserApiController {

    private final UserService userService;

    @PostMapping("/user")
    public String signup(AddUserRequest request) {
        userService.save(request);  // 회원 가입 메서드 호출
        return "redirect:/login";
    }

    @GetMapping("/logout")
    public String logout(HttpServletRequest request, HttpServletResponse response) {
        new SecurityContextLogoutHandler().logout(request, response, SecurityContextHolder.getContext().getAuthentication());
        return "redirect:/login";
    }
}

        2단계. articleList.html 구현

<!DOCTYPE html>
<html xmlns:th="http://www.thymeleaf.org">
<head>
  <meta charset="UTF-8">
  <title>블로그 글 목록</title>
  <link rel="stylesheet" href="https://stackpath.bootstrapcdn.com/bootstrap/4.1.3/css/bootstrap.min.css">
</head>
<body>
<div class="p-5 mb-5 text-center</> bg-light">
  <h1 class="mb-3">My Blog</h1>
  <h4 class="mb-3">블로그에 오신 것을 환영합니다.</h4>
</div>

<div class="container">
  <button type="button" id="create-btn"
          th:onclick="|location.href='@{/new-article}'|"
          class="btn btn-secondary btn-sm mb-3">글 등록</button>
  <div class="row-6" th:each="item : ${articles}">
    <div class="card">
      <div class="card-header" th:text="${item.id}">
      </div>
      <div class="card-body">
        <h5 class="card-title" th:text="${item.title}"></h5>
        <p class="card-text" th:text="${item.content}"></p>
        <a th:href="@{/articles/{id}(id=${item.id})}" class="btn btn-primary">보러가기</a>
      </div>
    </div>
    <br>
  </div>

  <button type="button" class="btn btn-secondary" onclick="location.href='logout'">로그아웃</button>
</div>

<script src="/js/article.js"></script>
</body>

8.7 실행 테스트