[Spring Security] 스프링 시큐리티 - 폼 기반 로그인/로그아웃, 회원가입 구현

Ogu·2023년 9월 15일
0

이번 시간에는 스프링 시큐리티로 폼 방식 로그인/로그아웃, 회원가입을 구현해보겠습니다.

회원 도메인 만들기

Spring Security 의존성 추가

우선 스프링 시큐리티를 사용하기 위한 의존성을 build.gradle 에 추가해줍니다.

    // spring security
    implementation 'org.springframework.boot:spring-boot-starter-security'
    implementation 'org.thymeleaf.extras:thymeleaf-extras-springsecurity6'
    testImplementation 'org.springframework.security:spring-security-test'

User Entity 생성

UserDetails 클래스를 상속받아 User 엔티티를 만듭니다.

@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")
    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"));
    }

    // 사용자의 id를 반환 (고유 값)
    @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 -> 잠금 X
    }

    // 패스워드의 만료 여부 반환
    @Override
    public boolean isCredentialsNonExpired() {
        // 패스워드 만료 여부 확인
        return true;  // true -> 만료 X
    }

    // 계정 사용 가능 여부 반환
    @Override
    public boolean isEnabled() {
        // 계정이 사용 가능한지 확인하는 로직
        return  true;  // true -> 사용 O
    }
}

UserDetails 클래스는 스프링 시큐리티에서 사용자의인증 정보를 담아 두는 인터페이스 입니다.
이 인터페이스를 상속하여 구현하면, 해당 클래스를 사용자 정보로 인식하고 인증 작업을 합니다.
따라서 필수적으로 오버라이드 해야하는 메서드들이 있습니다.

메서드반환타입설명
getAuthorities()Collection<? extends GrantedAuthority>사용자가 가지고 있는 권한의 목록을 리턴
getUsername()String사용자를 식별할 수 있는 사용자 이름 반환 (고유 값)
getPassword()String사용자의 비밀번호 반환 (비밀번호 암호화)
isAccountNonExpired()boolean계정이 만료되었는지 확인 (만료 X : true)
isAccountNonLocked()boolean계정이 잠금되었는지 확인 (잠금 X : true)
isCredentialsNonExpired()boolean비밀번호가 만료되었는지 확인 (만료 X : true)
isEnabled()boolean계정이 사용 가능한지 확인 (사용 O : true)

User Repository 생성

UserRepository.java 클래스를 생성하고, email로 사용자 정보를 가져오는 쿼리를 생성하는 메서드를 선언합니다.

public interface UserRepository extends JpaRepository<User, Long> {
    Optional<User> findByEmail(String email);
}

우리가 작성한 findByEmail 메서드가 요청하는 쿼리는 다음과 같습니다.

FROM users
WHERE email = #{email}

UserDetailService 생성

DB에서 유저 정보를 가져오는 인터페이스를 구현하기 위해 UserDetailsService를 상속받는 UserDetailService 클래스를 구현합니다.

@RequiredArgsConstructor
@Service
public class UserDetailService implements UserDetailsService {
    private final UserRepository userRepository;

    @Override
    public User loadUserByUsername(String email) {
        return userRepository.findByEmail(email)
                .orElseThrow(() -> new IllegalArgumentException(email));
    }
}

UserDetailsService도 역시 필수로 오버라이드 해야하는 메서드가 있습니다.

  • loadUserByUsername() : 사용자 정보를 가져오는 로직

시큐리티 설정

회원 도메인, 레포지토리, 서비스를 작성했으니, 실제 인증 처리를 위한 설정 파일 WebSecurityConfig.java를 구현합니다. 위치는 config 패키지 아래에 생성합니다.

💊 ~ is deprecated 오류

SpringSecurity 6.1 버전 이상, SpringBoot 3.1 버전 이후부터는 deprecated된 문법들이 있습니다.
직렬로 이어지는 메서드 체이닝을 지양하고, 함수형으로 바뀌었습니다.
필자는 부터 버전을 3.0버전으로 다운그레이드 시켜 진행하였지만, 변화된 방식으로 진행하고 싶으신 분들은 아래를 참고하세요.

package com.example.springboot3restapiblog.config;

import com.example.springboot3restapiblog.service.UserDetailService;
import lombok.RequiredArgsConstructor;
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 userDetailService;

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

    // 특정 HTTP 요청에 대한 웹 기반 보안 구성
    @Bean
    public SecurityFilterChain filterChain(HttpSecurity http) throws Exception {
        return http
                //authorizeRequest() deprecated 오류 해결(링크 : https://sennieworld.tistory.com/109)
                .authorizeHttpRequests()// 인증 인가 설정
                .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(userDetailService)
                .passwordEncoder(bCryptPasswordEncoder)
                .and()
                .build();
    }

    // 패스워드 인코더로 사용할 빈 등록
    @Bean
    public BCryptPasswordEncoder bCryptPasswordEncoder() {
        return new BCryptPasswordEncoder();
    }
}
  • WebSecurityCustomizer configure() 함수는 스프링 시큐리티의 모든 기능(인증, 인가)을 모든 곳에 모두 적용하지 않도록 설정하는 코드입니다. 일반적으로 정적 리소스에 스프링 시큐리티 사용을 비활성화 합니다. 해당 코드에서는 static 하위 경로에 있는 리소스와 h2의 데이터를 확인하는데 사용하는 h2-console 하위 url을 대상으로 ignore() 메서드를 사용합니다.

  • SecurityFilterChain filterChain(HttpSecurity http) throws Exception 함수는 특정 HTTP 요청에 대해 웹 기반 보안을 구성합니다. 인증/인가, 로그인, 로그아웃에 관련하여 설정합니다.

    • requestMatchers() : 특정 요청과 일치하는 url에 대한 액스 설정
    • permitAll() : 누구나 접근이 가능하도록 설정 (해당 코드에서는 "/login", "/signup", "/user"로 들어오는 요청들의 접근을 인증/인가 없이 허락)
    • anyRequest() : 위에서 설정한 url 의외의 요청에 대한 설정
    • authenticated() : 별도의 인가는 필요하지 않지만 인증 필요
    • formLogin() : 폼 기반 로그인 설정
    • logout() : 로그아웃 설정 - (logoutSuccessUrl() : 로그인 페이지 경로 설정, defaultSuccessUrl : 로그인이 완료되었을 때 이동할 경로 설정)
    • csrf().disable() : csrf 비활성화
  • AuthenticationManager authenticationManager() 함수는 인증 관리자 관련 설정을 합니다.

    • userDetailsService() : 사용자 정보를 가져올 서비스를 설정 (UserDetailsService를 상속받은 클래스)
    • passwordEncoder() : 비밀번호를 암호화하기 위한 인코더 설정
  • BCryptPasswordEncoder bCryptPasswordEncoder() - 패스워드 인코더로 사용할 빈을 등록합니다.

회원 가입 구현

회원 가입을 구현하기 위해 서비스 메서드, 컨트롤러를 작성하겠습니다.

서비스 메서드 코드 작성

AddUserRequest.java 생성

우선, 사용자 정보를 담을 dto인 AddUserRequest.java 파일을 작성합니다.

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

UserService.java 생성

다음으로, AddUserRequest를 받아 회원 정보를 추가하는 UserService.java 클래스를 생성하여 작성합니다.

@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();
    }
}
  • password(bCryptPasswordEncoder.encode(dto.getPassword())) 는 패스워드 인코딩용으로 등록한 빈을 사용해 패스워드를 암호화하여 저장합니다.

UserApiController.java 작성

폼에서 회원 가입 요청을 받으면 해당 사용자 정보를 저장하고, 로그인 페이지로 이동하는 sinup() 메서드를 작성합니다.

@RequiredArgsConstructor
@Controller
public class UserApiController {

    private final UserService userService;

    @PostMapping("/user")
    public String signup(AddUserRequest request) {
        userService.save(request);  // 회원 가입 메서드 호출
        return "redirect:/login";  // 회원 가입이 완료된 이후에 로그인 페이지로 이동
    }

}

회원가입, 로그인 뷰 구현

뷰 컨트롤러 구현

로그인, 회원가입 경로로 접근 시 각 화면으로 연결하는 컨트롤러를 작성합니다.

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

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

회원가입, 로그인 뷰 구현

각 뷰는 templates 디렉터리 하위에 작성하였으며, 다음 링크를 참고해 복사하여 사용했습니다.

로그아웃 구현

LogoutFilter의 로직은 다음과 같습니다.

  • 우선, logoutUrl 메소드로 지정한 url로 온 요청을 확인합니다.
  • 해당 url의 요청이라면 SecurityContext에서 인증 객체를 꺼내
  • SecurityContetLogoutHandler 에 전달하여 세션을 무효화하고 쿠키를 삭제합니다.
  • 로그아웃 완료 후 SimpleUrlLougoutSuccessHandler를 호출하여 로그인 페이지로 이동합니다.

UserApiController.java에 logout() 메서드 추가

UserApiController 클래스에 다음 logout() 메서드를 추가합니다.

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

로그아웃을 담당하는 핸들러인 SecurityContextLogoutHandlerlogout() 메서드를 호출해 로그아웃 합니다.
SecurityContextLogoutHandler().logout()을 자세히 보면 다음과 같습니다.

로그아웃 뷰(버튼) 추가

다음과 같이 로그아웃 버튼과 script를 추가합니다.

... 생략

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

실행 테스트

application.yml 설정

db정보 추가 및 h2 콘솔 활성화를 위해 다음과 같이 추가합니다.

spring:
  datasource:
    url:  jdbc:h2:mem:testdb
    username: sa
  h2:
    console:
      enabled: true
  jpa:
    #전송 쿼리 확인
    show-sql: true
    properties:
      hibernate:
        format_sql: true

로그인, 회원가입 실행 테스트

스프링 부트 서버를 실행시켜 http://localhost:8080/articles 에 접근하면 /articles 는 인증된 사용자만 들어갈 수 있으므로 로그인 페이지 /login 으로 리다이렉트가 되는 것을 확인할 수 있습니다.

회원 가입 버튼을 누르거나, http://localhost:8080/signup 로 이동하여 회원 가입 페이지로 이동해 회원 가입을 진행합니다.

가입한 정보로 다시 로그인하면 /articles 페이지로 이동하는 것을 확인할 수 있습니다.

http://localhost:8080/h2-console 에 접속하여 다음과 같이 정보를 입력해 연결합니다.

Users 테이블을 조회하니 가입한 회원 정보가 들어있는 것을 볼 수 있습니다.
또한 PASSWORD 또한 암호화 되어 저장된 것을 확인할 수 있습니다.

로그아웃 테스트

화면 하단의 로그아웃 버튼을 눌러 로그아웃을 눌러봅니다.

로그아웃이 정상적으로 진행되어 다시 로그인 페이지로 리다이렉트 되는 것을 확인할 수 있습니다.

지금까지 폼 로그인 방식 기반의 스프링 시큐리티를 활용한 로그인, 회원가입, 로그아웃을 구현해 보았습니다.
스프링 시큐리티는 필터 기반으로 동작하며, 각 필터에서 세션 & 쿠키 방식으로 인증, 인가를 처리합니다.

다음 장에서는 Oauth2와 JWT 를 활용한 인증, 인가를 구현해보겠습니다.






본 포스팅은 스프링 부트 3 개백엔드 개발자되기 (자바편) 을 기반으로 작성되었습니다.

profile
私はゲームと日本が好きなBackend Developer志望生のOguです🐤🐤

0개의 댓글