프로젝트를 만들기에 앞서 Spring Security 공부를 위해 로그인기능을 구현하였다.
🖥 백엔드
🎨 프론트앤드
📃 DB
├─src
├─|─main
├─java
│ └─com
│ └─security
│ └─demo
│ └─security
│ ├─config
│ ├─controller
│ ├─dto
│ ├─entity
│ ├─repository
│ └─service
└─resources
├─db
│ └─migration
├─static
│ ├─fonts
│ └─js
└─templates
<?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>
경로 src/main/resources
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
경로 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)
)
@SpringBootApplication
public class DemoApplication {
public static void main(String[] args) {
SpringApplication.run(DemoApplication.class, args);
}
}
경로 src/main/java/.../entity
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;
}
}
경로 src/main/java/.../repository
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);
}
경로 src/main/java/.../service
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));
}
}
경로 src/main/java/.../config
실제 인증처리를 담당하는 Security 설정 파일
🧩configure() : 스프링 시큐리티를 정적 리소스(여기서는 /static) 아래 경로에는 비활성화한다.
🧩filterChain() : 인증/인가 및 로그인, 로그아웃 설정
🧩authenticationManager() : 인증 관리자 관련 설정, 사용자 정보를 가져올 서비스를 재정의 하거나 인증방법(LDAP, JDBC 기반 인증)등을 설정함.
🧩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();
}
}
경로 src/main/java/.../controller
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";
}
}
경로 src/main/java/.../dto
import lombok.Getter;
import lombok.Setter;
@Getter
@Setter
public class AddUserRequest {
private String email;
private String password;
}
경로 src/main/java/.../service
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();
}
}
경로 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";
}
}
프론트 코드는 다음 글에서 이어서 작성하도록 한다.