Spring Security 6.x 적용 (백엔드 Spring Boot 3.x, 프론트 Vue3) 기본 인증 프로젝트

mocaccino·2024년 11월 5일
0

개인 프로젝트

목록 보기
1/2

프로젝트를 만들기에 앞서 Spring Security 공부를 위해 로그인기능을 구현하였다.

🌳 프로젝트 구현 기능

  1.  로그인 
    🔹 User DB 에 저장되어 있는 email, password와 매칭해서 로그인 성공/실패
    🔹 로그인 성공 시 Session Storage에 UserData를 저장하고 /main 페이지로 이동
    🔹 로그인 전에는 /login 페이지, /signup 페이지 외에는 접근 불가
  2.  로그아웃 
    🔹 로그아웃 시 Session Storage에 있는 UserData를 삭제
  3.  회원가입 
    🔹 회원가입에서 입력한 정보로 User DB에 email, password 저장

🌳 프로젝트 개발 환경

🖥 백엔드

  • Spring Boot 3.3.2 (maven 프로젝트)
  • Java17
  • 사용기술 : JPA, lombok, flyway
  •  port   8084

🎨 프론트앤드

  • Vue3
  • 사용기술 : vue router, pinia, vuetify, axios
  •  port   8085

📃 DB

  • postgres

🌳 백엔드

백엔드 파일 구조

├─src
├─|─main
     ├─java
     │  └─com
     │      └─security
     │          └─demo
     │              └─security
     │                  ├─config
     │                  ├─controller
     │                  ├─dto
     │                  ├─entity
     │                  ├─repository
     │                  └─service
     └─resources
         ├─db
         │  └─migration
         ├─static
         │  ├─fonts
         │  └─js
         └─templates

1. pom.xml

  • spring security 사용
  • DB postgres 사용
  • DB 형상관리를 위해 flyway 사용
  • 간편한 코드 구현 및 log 사용을 위한 lombok 사용
<?xml version="1.0" encoding="UTF-8"?>
<project xmlns="http://maven.apache.org/POM/4.0.0"
	xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
	xsi:schemaLocation="http://maven.apache.org/POM/4.0.0 https://maven.apache.org/xsd/maven-4.0.0.xsd">
	<modelVersion>4.0.0</modelVersion>
	<parent>
		<groupId>org.springframework.boot</groupId>
		<artifactId>spring-boot-starter-parent</artifactId>
		<version>3.3.2</version>
		<relativePath /> <!-- lookup parent from repository -->
	</parent>
	<groupId>com.security</groupId>
	<artifactId>demo</artifactId>
	<version>0.0.1-SNAPSHOT</version>
	<name>demo</name>
	<description>Demo project for Spring Boot</description>
	<url />
	<licenses>
		<license />
	</licenses>
	<developers>
		<developer />
	</developers>
	<scm>
		<connection />
		<developerConnection />
		<tag />
		<url />
	</scm>
	<properties>
		<java.version>17</java.version>
	</properties>
	<dependencies>
		<dependency>
			<groupId>org.springframework.boot</groupId>
			<artifactId>spring-boot-starter-web</artifactId>
		</dependency>
		<dependency>
			<groupId>org.springframework.boot</groupId>
			<artifactId>spring-boot-starter-websocket</artifactId>
		</dependency>
		<dependency>
		    <groupId>org.springframework.boot</groupId>
		    <artifactId>spring-boot-starter-security</artifactId>
		</dependency>
		<dependency>
		    <groupId>org.springframework.security</groupId>
		    <artifactId>spring-security-test</artifactId>
		    <scope>test</scope>
		</dependency>
		<dependency>
		    <groupId>org.springframework.boot</groupId>
		    <artifactId>spring-boot-starter-data-jpa</artifactId>
		</dependency>
		<dependency>
			<groupId>org.projectlombok</groupId>
			<artifactId>lombok</artifactId>
			<version>1.18.24</version>
			<scope>provided</scope>
		</dependency>
		<dependency>
		    <groupId>org.postgresql</groupId>
		    <artifactId>postgresql</artifactId>
		    <version>42.7.3</version>
		</dependency>
		<dependency>
		    <groupId>org.springframework.boot</groupId>
		    <artifactId>spring-boot-starter-jdbc</artifactId>
		    <version>3.3.0</version>
		</dependency>
		<dependency>
		   <groupId>org.flywaydb</groupId>
		   <artifactId>flyway-core</artifactId>
		</dependency>
		<dependency>
		    <groupId>org.flywaydb</groupId>
		    <artifactId>flyway-database-postgresql</artifactId>
		</dependency>
		<dependency>
		    <groupId>org.springframework.boot</groupId>
		    <artifactId>spring-boot-starter-data-jpa</artifactId>
		</dependency>
	</dependencies>

	<build>
		<plugins>
			<plugin>
				<groupId>org.springframework.boot</groupId>
				<artifactId>spring-boot-maven-plugin</artifactId>
			</plugin>
		</plugins>
	</build>

</project>

2. application.properties

 경로   src/main/resources

  • 프론트앤드와 백엔드 분리를 위해 8084 포트로 설정
  • 로그인할 사용자 정보를 담은 DB postgres 설정
  • DB 형상관리를 위한 flyway 정보 설정
spring.application.name=demo
server.port=8084

spring.datasource.hikari.maximum-pool-size=4
spring.datasource.url=jdbc:postgresql://127.0.0.1:5432/postgres
spring.datasource.username=postgres
spring.datasource.password=password

#jpa
spring.jpa.hibernate.ddl-auto=validate

#flyway
spring.flyway.enabled=true
spring.flyway.baseline-on-migrate=true
spring.flyway.locations=classpath:/db/migration
spring.flyway.baseline-version=0

3. V1__User.sql

 경로   src/main/resources/db/migration

CREATE TABLE USERS (
	ID BIGINT NOT NULL GENERATED ALWAYS AS IDENTITY,
	EMAIL VARCHAR(50) NOT NULL,
	PASSWORD VARCHAR(200) NOT NULL,
	PRIMARY KEY (ID)
)

4. DemoApplication

@SpringBootApplication
public class DemoApplication {

	public static void main(String[] args) {
		SpringApplication.run(DemoApplication.class, args);
	}
}

5. User 엔티티

 경로   src/main/java/.../entity

  • UserDetails 클래스를 상속하는 User 클래스 생성

    UserDetails 클래스는 Spring Security에서 사용자의 인증 정보를 담아두는 인터페이스이다. 스프링 시큐리티에서 해당 객체를 통해 인증 정보를 가져오려면 필수 오버라이드 메서드를 구현 해주어야 한다.

    ➡️ User 클래스에서는 필수적인 오버라이드 메서드만 구현하였다.

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

import org.springframework.security.core.GrantedAuthority;
import org.springframework.security.core.authority.SimpleGrantedAuthority;
import org.springframework.security.core.userdetails.UserDetails;

import jakarta.persistence.Column;
import jakarta.persistence.Entity;
import jakarta.persistence.GeneratedValue;
import jakarta.persistence.GenerationType;
import jakarta.persistence.Id;
import jakarta.persistence.Table;
import lombok.AccessLevel;
import lombok.Builder;
import lombok.Getter;
import lombok.NoArgsConstructor;

@Table(name = "users")
@NoArgsConstructor(access = AccessLevel.PROTECTED)
@Getter
@Entity
public class User implements 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, unique = true)
	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 getPassword() {
		return password;
	}

	@Override
	public String getUsername() {
		return email;
	}

	// 계정 만료 여부 반환
	@Override
	public boolean isAccountNonExpired() {
		return true;
	}

	// 계정 잠금 여부 반환
	@Override
	public boolean isAccountNonLocked() {
		return true;
	}

	// 패스워드 만료 여부 반환
	@Override
	public boolean isCredentialsNonExpired() {
		return true;
	}

	// 계정 사용 여부 반환
	@Override
	public boolean isEnabled() {
		return true;
	}
}

6. UserRepository

 경로   src/main/java/.../repository

  • JPA 사용하기 때문에 JpaRepository를 상속받는다.
  • JPA 규칙에 맞도록 findByEmail 메서드를 선언하여 email로 사용자 정보를 가져오도록 한다.
import java.util.Optional;
import org.springframework.data.jpa.repository.JpaRepository;
import com.websocket.demo.messagingstompwebsocket.entity.User;

public interface UserRepository extends JpaRepository<User, Long> {

	Optional<User> findByEmail(String email);
}

7. UserDetailService

 경로   src/main/java/.../service

  • Spring Security에서 로그인 할 때 사용자 정보를 가져오는 로직
  • 스프링 시큐리티에서 사용자 정보를 가져오는 인터페이스인 UserDetailsService를 상속받아 구현한다.
import org.springframework.security.core.userdetails.UserDetailsService;
import org.springframework.security.core.userdetails.UsernameNotFoundException;
import org.springframework.stereotype.Service;

import com.websocket.demo.messagingstompwebsocket.entity.User;
import com.websocket.demo.messagingstompwebsocket.repository.UserRepository;

import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;

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

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

}

8. WebSecurityConfig

 경로   src/main/java/.../config
실제 인증처리를 담당하는 Security 설정 파일

🧩configure() : 스프링 시큐리티를 정적 리소스(여기서는 /static) 아래 경로에는 비활성화한다.
🧩filterChain() : 인증/인가 및 로그인, 로그아웃 설정

  • requestMatchers()안의 url에 대해서는 permitAll() 누구나 접근 가능하게 설정한다.
  • anyRequest() 위에서 설정한 url 외에는 authenticated() 별도의 인가는 필요하지 않지만 인증이 성공된 상태여야 접근 가능하다.
  • fromLogin() 폼 로그인 기반 설정, 로그인이 성공하면 커스텀 한 성공 핸들러(customSuccessHandler())에 따라 동작한다.
  • 로그인이 실패하면 customAuthenticationFailureHandler()에 따라 동작한다.
  • csrf를 비활성화 한다.

🧩authenticationManager() : 인증 관리자 관련 설정, 사용자 정보를 가져올 서비스를 재정의 하거나 인증방법(LDAP, JDBC 기반 인증)등을 설정함.

  • 사용자 정보를 가져올 서비스를 설정한다. userDetailsService()를 userService로 설정한다. 이때 설정하는 서비스 클래스는 반드시 userDetailsService를 상속 받은 클래스여야 한다.
  • 비밀번호를 암호화하기 위한 인코더로 passwordEncoder를 설정한다.

🧩bCryptPasswordEncoder() : 패스워드 인코더를 빈으로 등록한다.

import java.io.IOException;
import java.util.Arrays;

import org.springframework.beans.factory.annotation.Autowired;
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.core.Authentication;
import org.springframework.security.crypto.bcrypt.BCryptPasswordEncoder;
import org.springframework.security.web.SecurityFilterChain;
import org.springframework.security.web.authentication.AuthenticationFailureHandler;
import org.springframework.security.web.authentication.AuthenticationSuccessHandler;
import org.springframework.web.cors.CorsConfiguration;
import org.springframework.web.cors.CorsConfigurationSource;
import org.springframework.web.cors.UrlBasedCorsConfigurationSource;

import com.websocket.demo.messagingstompwebsocket.service.UserDetailService;

import jakarta.servlet.http.HttpServletRequest;
import jakarta.servlet.http.HttpServletResponse;
import lombok.RequiredArgsConstructor;

@RequiredArgsConstructor
@Configuration
public class WebSecurityConfig {

	@Autowired
	private final UserDetailService userService;
	
	@Bean
	public WebSecurityCustomizer configure() {
		return (web) -> web.ignoring()
				.requestMatchers("/static/**");
	}
	
	//CORS 설정(Webconfig에 설정한 CORS config와는 별도로 Spring Security에서의 CORS 설정이 필요하다) 
    @Bean
    public CorsConfigurationSource corsConfigurationSource() {
        CorsConfiguration config = new CorsConfiguration();

        config.setAllowCredentials(true);
        config.setAllowedOrigins(Arrays.asList("http://localhost:8085"));
        config.setAllowedMethods(Arrays.asList("HEAD","POST","GET","DELETE","PUT"));
        config.setAllowedHeaders(Arrays.asList("*"));

        UrlBasedCorsConfigurationSource source = new UrlBasedCorsConfigurationSource();
        source.registerCorsConfiguration("/**", config);
        return source;
    }

	@Bean
	public SecurityFilterChain filterChain(HttpSecurity http) throws Exception {
		return http
				.cors(cors -> cors.configurationSource(corsConfigurationSource()))
				.authorizeRequests()
				.requestMatchers("/login", "/signup", "/user", "/ws/**").permitAll()
				.anyRequest().authenticated()
				.and()
				.formLogin()
				.loginProcessingUrl("/login")
                .successHandler(customSuccessHandler()) // 커스텀 성공 핸들러 설정
                .failureHandler(customAuthenticationFailureHandler())  // 커스텀 실패 핸들러 추가
                .permitAll()
				.and()
				.logout()
				.logoutSuccessUrl("/login")
				.invalidateHttpSession(true)
				.and()
				.csrf().disable()
				.build();
				
	}
	
	// 커스텀 AuthenticationSuccessHandler 빈 생성
    @Bean
    public AuthenticationSuccessHandler customSuccessHandler() {
    	 return new AuthenticationSuccessHandler() {
             @Override
             public void onAuthenticationSuccess(HttpServletRequest request, HttpServletResponse response, Authentication authentication) throws IOException {
                 response.setStatus(HttpServletResponse.SC_OK);
                 response.setContentType("application/json");
                 response.getWriter().write("{\"message\":\"login\"}");
                 response.getWriter().flush();
             }
         };
    }
    
    @Bean
    public AuthenticationFailureHandler customAuthenticationFailureHandler() {
        return (request, response, exception) -> {
            response.setStatus(HttpServletResponse.SC_UNAUTHORIZED);
            response.setContentType("application/json");
            response.getWriter().write("{\"message\":\"Unauthorized\"}");
            response.getWriter().flush();
        };
    }
    
	
	@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();
	}
	
}

9. UserViewController

 경로   src/main/java/.../controller

  • 로그인(/login), 회원가입(/signup), 로그아웃(/logout) 경로로 접근하면 뷰 로 연결시키는 컨트롤러
import org.springframework.stereotype.Controller;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.PostMapping;

import lombok.RequiredArgsConstructor;

@RequiredArgsConstructor
@Controller
public class UserViewController {

	@PostMapping("/login")
	public String login() {

		return "login";
	}

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

}

10. AddUserRequest

 경로   src/main/java/.../dto

  • 회원가입하는 사용자의 정보를 담고 있는 dto 객체
import lombok.Getter;
import lombok.Setter;

@Getter
@Setter
public class AddUserRequest {

	private String email;
	private String password;
}

11. UserService

 경로   src/main/java/.../service

  • 회원 정보를 추가하는 메서드
  • 패스워드를 저장할 때 시큐리티를 설정해서 패스워드 인코더로 설정한 bCryptPasswordEncoder를 사용해 암호화 해서 저장한다.
import org.springframework.security.crypto.bcrypt.BCryptPasswordEncoder;
import org.springframework.stereotype.Service;

import com.websocket.demo.messagingstompwebsocket.dto.AddUserRequest;
import com.websocket.demo.messagingstompwebsocket.entity.User;
import com.websocket.demo.messagingstompwebsocket.repository.UserRepository;

import lombok.RequiredArgsConstructor;

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

12. UserApiController

 경로   src/main/java/.../controller

  • 회원 가입 폼에서 회원가입 요청 버튼을 누르면 서비스 메서드를 사용해서 사용자를 저장하고 로그인 페이지로 이동한다.
import org.springframework.stereotype.Controller;
import org.springframework.web.bind.annotation.PostMapping;
import org.springframework.web.bind.annotation.RequestBody;

import com.websocket.demo.messagingstompwebsocket.dto.AddUserRequest;
import com.websocket.demo.messagingstompwebsocket.service.UserService;

import lombok.RequiredArgsConstructor;

@RequiredArgsConstructor
@Controller
public class UserApiController {

	private final UserService userService;
	
	@PostMapping("/user")
	public String signup(@RequestBody AddUserRequest request) {
		userService.save(request);
		return "redirect:/login";
	}
	
}

🌳 프론트

프론트 코드는 다음 글에서 이어서 작성하도록 한다.

profile
레거시문서를 줄이자. 계속 업데이트해서 최신화한다.

0개의 댓글