이번 시간에는 스프링 시큐리티로 폼 방식 로그인/로그아웃, 회원가입을 구현해보겠습니다.
우선 스프링 시큐리티를 사용하기 위한 의존성을 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'
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) |
UserRepository.java 클래스를 생성하고, email로 사용자 정보를 가져오는 쿼리를 생성하는 메서드를 선언합니다.
public interface UserRepository extends JpaRepository<User, Long> {
Optional<User> findByEmail(String email);
}
우리가 작성한 findByEmail
메서드가 요청하는 쿼리는 다음과 같습니다.
FROM users
WHERE email = #{email}
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버전으로 다운그레이드 시켜 진행하였지만, 변화된 방식으로 진행하고 싶으신 분들은 아래를 참고하세요.
- https://docs.spring.io/spring-security/reference/servlet/authorization/authorize-http-requests.html
- https://velog.io/@letsdev/Spring-Boot-3.1Spring-6.1-Security-Config-csrf-is-deprecated-and-marked-for-removal
- https://dmaolon00.tistory.com/entry/authorizeRequests-is-deprecated-%ED%95%B4%EA%B2%B0-Spring-Security-Configuration
- https://github.com/shinsunyoung/springboot-developer/issues/5
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()
- 패스워드 인코더로 사용할 빈을 등록합니다.
회원 가입을 구현하기 위해 서비스 메서드, 컨트롤러를 작성하겠습니다.
우선, 사용자 정보를 담을 dto인 AddUserRequest.java
파일을 작성합니다.
@Getter
@Setter
public class AddUserRequest {
private String email;
private String password;
}
다음으로, 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의 로직은 다음과 같습니다.
UserApiController
클래스에 다음 logout() 메서드를 추가합니다.
@GetMapping("/logout")
public String logout(HttpServletRequest request, HttpServletResponse response) {
new SecurityContextLogoutHandler().logout(request, response,
SecurityContextHolder.getContext().getAuthentication());
return "redirect:/login";
}
로그아웃을 담당하는 핸들러인 SecurityContextLogoutHandler
의 logout()
메서드를 호출해 로그아웃 합니다.
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>
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 개백엔드 개발자되기 (자바편)
을 기반으로 작성되었습니다.