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

·2023년 12월 3일
2

Spring Boot

목록 보기
14/21
post-thumbnail

✨ 본격적으로 시작!

1. 회원 도메인 생성

1-1. 의존성 추가

gradle.build

dependencies {
	(생략)
    // 스프링 시큐리티를 사용하기 위한 스타터 추가
    implementation 'org.springframework.boot:spring-boot-starter-security'
    // 타임리프에서 스프링 시큐리티를 사용하기 위한 의존성 추가
    implementation 'org.thymeleaf.extras:thymeleaf-extras-springsecurity6'
    // 스프링 시큐리티를 테스트하기 위한 의존성 추가
    testImplementation 'org.springframework.security:spring-security-test'
}

1-2. 엔티티 생성

User.java

package me.ansoohyeon.springbootdeveloper.domain;

import jakarta.persistence.*;
import lombok.AccessLevel;
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(access = AccessLevel.PROTECTED)
@Getter
@Entity
public class User implements UserDetails { // UserDetails를 상속받아 인증 객체로 사용

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

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

    @Column(name = "password", nullable = false)
    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 email;
    }

    @Override
    public String getPassword() {
        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 -> 사용 가능
    }
}

1-3. 리포지터리 생성

UserRepository.java

package me.ansoohyeon.springbootdeveloper.repository;

import me.ansoohyeon.springbootdeveloper.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); // 이메일로 사용자 정보를 가져옴
}

1-4. 서비스 메소드 코드 작성

UserDetailService.java

package me.ansoohyeon.springbootdeveloper.service;

import lombok.RequiredArgsConstructor;
import me.ansoohyeon.springbootdeveloper.domain.User;
import me.ansoohyeon.springbootdeveloper.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)));
    }
}

2. 시큐리티 설정

WebSecurityConfig.java

package me.ansoohyeon.springbootdeveloper.config;

import lombok.RequiredArgsConstructor;
import me.ansoohyeon.springbootdeveloper.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();
    }
}

이 부분이 굉장히 빡셈.... 🤯


3. 회원 가입 구현

3-1. 서비스 메소드 코드 작성

AddUserRequest.java

package me.ansoohyeon.springbootdeveloper.dto;

import lombok.Getter;
import lombok.Setter;

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

UserService.java

package me.ansoohyeon.springbootdeveloper.service;

import lombok.RequiredArgsConstructor;
import me.ansoohyeon.springbootdeveloper.domain.User;
import me.ansoohyeon.springbootdeveloper.dto.AddUserRequest;
import me.ansoohyeon.springbootdeveloper.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();
    }

}

3-2. 컨트롤러 작성

UserApiController.java

package me.ansoohyeon.springbootdeveloper.controller;

import lombok.RequiredArgsConstructor;
import me.ansoohyeon.springbootdeveloper.dto.AddUserRequest;
import me.ansoohyeon.springbootdeveloper.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 "redirect:/login"; // 회원 가입이 완료된 후 로그인 페이지로 이동
    }
}

4. 회원 가입, 로그인 뷰 작성

4-1. 뷰 컨트롤러 구현

UserViewController.java

package me.ansoohyeon.springbootdeveloper.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";
    }
}

4-2. 뷰 작성

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>

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>

5. 로그아웃 구현

5-1. 로그아웃 메소드 추가

UserApiController.java

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

5-2. 로그아웃 뷰 추가

articleList.html

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

<script src="/js/article.js"></script>
(생략)

6. 실행 테스트

6-1. 테스트를 위한 환경 변수 추가

application.yml

(생략)
  datasource: # 데이터베이스 정보 추가
    url: jdbc:h2:mem:testdb
    username: sa

  h2: # H2 콘솔 활성화
    console:
      enabled: true

6-2. 실행 테스트 시작

이제 스프링 부트 서버를 실행하고 웹 브라우저에서 url을 입력하고 접속해 본다.

정말 로그인 화면이 구현되었다! 회원 가입도 해 본다. 그리고 H2 Console에 로그인한다.

콘솔창에 회원 테이블 전체 조회 sql문을 실행해 보면, 방금 회원 가입한 데이터가 성공적으로 insert 된 것을 볼 수 있다!


마무리하며

이렇게 스프링 시큐리티를 사용하여 로그인, 로그아웃, 회원 가입까지 모두 구현해 보았다. 복잡안 인증과 인가를 보다 편리하게 구현할 수 있다는 점에서 굉장한 기능인 것 같다..! 👍

profile
풀스택 개발자 기록집 📁

0개의 댓글