스프링 시큐리티에 대해서 더 알고 싶은 분은 이론편을 먼저 참고해주세요 ❗️
➡️ [이론편] 스프링 시큐리티란 ?
구현한 화면은 회원가입, 로그인, 마이, 관리자, 로그아웃으로 총 5개 이고,
화면 | 누구나 | 회원(USER) | 관리자(ADMIN) |
---|---|---|---|
회원가입, 로그인 | O | O | O |
마이, 로그아웃 | X | O | O |
관리자 | X | X | O |
위와 같이 권한에 따라 접속 가능한 화면이 다릅니다
구현된 화면은 아래 움짤을 확인주세요 ⬇️
간단한 플로우는 다음과 같습니다.
1. 로그인을 실패하는 경우 실패 화면(/fail) 로 이동하고
2. 회원가입 성공 -> 로그인 성공 -> 마이페이지 로 이동하며
3. 마이페이지에서는 권한에 따라 admin 화면으로 이동하거나 이동하지 못합니다.
사용할 dependencies 들을 build.gradle 에 추가합니다.
프레임워크로는 Spring Boot, 언어로는 Java, DB 는 H2를 사용하고 thymeleaf 을 뷰템플릿으로 사용합니다.
build.gradle
dependencies {
implementation 'org.springframework.boot:spring-boot-starter-data-jpa'
implementation 'org.springframework.boot:spring-boot-starter-web'
implementation 'org.springframework.boot:spring-boot-starter-security'
implementation 'org.springframework.boot:spring-boot-starter-thymeleaf'
annotationProcessor 'org.projectlombok:lombok'
runtimeOnly 'com.h2database:h2'
}
spring boot 를 시작하기 위해서 가장 기본적인 라이브러리와 스프링 시큐리티를 사용하기 위해 제일 중요한 🌟spring-boot-starter-security🌟 를 추가해줬습니다.
이외 dependency 에 대한 설명은 아래 표를 참고해주세요.
dependency | 설명 |
---|---|
spring-boot-starter-data-jpa | JPA 를 사용하기 위한 라이브러리 |
spring-boot-starter-web | 웹 API 라이브러리 |
spring-boot-starter-security | spring security 라이브러리 |
spring-boot-starter-thymeleaf | view 템플릿은 thymeleaf를 사용 |
org.projectlombok:lombok | 어노테이션 기반으로 코드를 자동완성 해주는 라이브러리 |
com.h2database:h2 | h2 DB 를 사용하기 위해 추가하는 라이브러리 |
application.yml
spring:
h2:
console:
enabled: true // 웹 콘솔 사용 여부
datasource:
url: jdbc:h2:mem:testdb // 인 메모리 DB 로 동작하는 testdb 스키마
driver-class-name: org.h2.Driver
username: sa
password:
h2 디비를 사용하기 위해서 application.yml 파일에 관련 설정을 해줍니다
WebSecurityConfig.java
@Configuration
@EnableWebSecurity
@RequiredArgsConstructor
public class WebSecurityConfig extends WebSecurityConfigurerAdapter { // 정적 자원에 대해서는 Security 설정을 적용하지 않음.
private final UserDetailsService userDetailsService;
@Override
public void configure(WebSecurity web) {
web
.ignoring() // spring security 필터 타지 않도록 설정
.antMatchers("/resources/**") // 정적 리소스 무시
.antMatchers("/h2-console/**"); // h2-console 무시
}
@Override
protected void configure(HttpSecurity http) throws Exception {
// 1. URL 별 권한 설정
// 2. login, logout url 과 성공했을 때, 실패했을 때 설정
http.csrf().disable().authorizeRequests()
.antMatchers("/login", "/signup").permitAll() // /login, /signup 은 인증 안해도 접근 가능하도록 설정
.antMatchers("/admin").hasRole("ADMIN") // /admin 은 어드민 유저만 가능하도록 설정
.antMatchers("/my").authenticated() // /my 은 인증이 되야함
.and()
.formLogin() // form 을 통한 login 활성화
.loginPage("/login") // 로그인 페이지 URL 설정 , 설정하지 않는 경우 default 로그인 페이지 노출
.successHandler(customLoginSuccessHandler())
.failureForwardUrl("/fail") // 로그인 실패 URL 설정
.and()
.logout()
.logoutUrl("/logout") // 로그아웃 URL 설정
}
@Bean
public BCryptPasswordEncoder bCryptPasswordEncoder() {
// 비밀번호 암호화 할때 사용할 BCrypthPasswordEncoder 를 빈으로 등록
return new BCryptPasswordEncoder();
}
@Bean
public CustomLoginSuccessHandler customLoginSuccessHandler() {
// 성공할 때 실행되어야 하는 CustomLoginSuccessHandler 를 빈으로 등록
return new CustomLoginSuccessHandler();
}
@Bean
public CustomAuthenticationProvider customAuthenticationProvider() {
// 실제 인증 당담 객체를 빈으로 등록
return new CustomAuthenticationProvider(userDetailsService, bCryptPasswordEncoder());
}
@Override
public void configure(AuthenticationManagerBuilder authenticationManagerBuilder) {
// 커스텀한 AuthenticationProvider 를 AuthenticationManager 에 등록
authenticationManagerBuilder.authenticationProvider(customAuthenticationProvider());
}
}
WebSecurityConfigurerAdapter
을 상속받은 클래스에 config 클래스에 @EnableWebSecurity
어노테이션을 달면 SpringSecurityFilterChain 이 자동으로 포함됩니다.
스프링 시큐리티 관련해서는 protected void configure(HttpSecurity http) 와 public void configure(AuthenticationManagerBuilder authenticationManagerBuilder) 를 유심히 봐주시면 됩니다
@Override
protected void configure(HttpSecurity http) throws Exception {
http.csrf().disable().authorizeRequests()
.antMatchers("/login", "/signup").permitAll()
.antMatchers("/admin").hasRole("ADMIN")
.antMatchers("/my").authenticated()
.and()
.formLogin()
.loginPage("/login")
.successHandler(customLoginSuccessHandler())
.failureForwardUrl("/fail")
.and()
.logout()
.logoutUrl("/logout")
}
해당 메소드에서는 HttpSecurity를 이용해 스프링 시큐리티 관련 설정을 해줍니다.
현재는 permitAll(), hasRole(), authenticated() 만 사용해서 권한 설정을 해주고 있는데 권한 설정 관련 메소드들은 다음과 같습니다
이름 | 설명 |
---|---|
authenticated() | 인증된 사용자의 접근을 허용 |
permitAll() | 모든 사용자 허용 |
denyAll() | 모든 사용자 거부 |
hasRole(Role) | Role 에 해당하는 사용자만 허용 |
hasAnyRole(Roles...) | Role 중 하나라도 해당하면 허용 |
hasIpAddress | 해당 IP 를 가지고 있는 사용자인 경우 허용 |
로그인, 로그아웃 관련해서는 아래에서 설명하겠습니다.
@Override
public void configure(AuthenticationManagerBuilder authenticationManagerBuilder) {
authenticationManagerBuilder.authenticationProvider(customAuthenticationProvider());
}
AuthenticationManager의 구현체인 ProviderManager 에서 AuthenticationProvider 의 목록을 위임받아서 유효한지 아닌지 여부를 판단하는데 여기에 커스텀하게 만든 AuthenticationProvider 을 AuthenticationManager 에 등록해줌으로써 DB 에 데이터가 정상적으로 있는지 없는지 여부를 확인해줍니다.
다른 자세한 설명을 주석을 참고해주세요.
UserCreateRequest.java
@Getter
@Setter
public class UserCreateRequest {
@NotNull
private String email; // 이메일
@NotNull
private String passWord; // 비밀번호
@NotNull
private UserRole userRole; // 권한 - 어드민 & 유저
}
회원가입 request
3가지 값 모두 필수적인 값이기 때문에 @NotNull 로 선언해줍니다.
UserRole.java
@AllArgsConstructor
@Getter
public enum UserRole {
USER("ROLE_USER"),
ADMIN("ROLE_ADMIN");
private String value;
}
권한은 USER (일반 사용자), ADNIN (어드민 사용자) 총 두개로 나뉘며 어드민 사용자 같은 경우에는 모든 사용자 정보를 볼 수 있는 어드민 페이지를 접속할 수 있습니다.
Users.java
@AllArgsConstructor
@Getter
@Entity
public class Users implements UserDetails {
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
@Column
private Long id;
@Column(unique = true) // email 은 중복되지 않아야 하기 때문에 uniquer 하도록 설정
private String email;
@Column
private String password;
@Column
@Enumerated(EnumType.STRING)
private UserRole userRole;
@Builder
private Users(String password, UserRole userRole, String email) {
this.password = password;
this.userRole = userRole;
this.email = email;
}
@Override
public Collection<? extends GrantedAuthority> getAuthorities() {
// 계정의 권한 목록을 리턴
Set<GrantedAuthority> roles = new HashSet<>();
roles.add(new SimpleGrantedAuthority(userRole.getValue()));
return roles;
}
@Override
public String getPassword() {
return this.password; // 계정의 비밀번호 리턴
}
@Override
public String getUsername() {
return this.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; // 계정의 활성화 여부 리턴
}
}
User Entity 에서 UserDetails (사용자 정보를 담는 인터페이스) 를 implements 해줍니다.
UserController.java
@PostMapping("/signup")
public void createUser(UserCreateRequest userCreateRequest, HttpServletResponse response) throws IOException {
usersService.createUser(userCreateRequest);
response.sendRedirect("/login");
}
/signup API 를 만들어주고 성공하는 경우 login 페이지로 리다이렉트 시켜줍니다.
UserServiceImpl.java
@Override
public UserDTO createUser(UserCreateRequest userCreateRequest) {
Users users = userRepository.save(
Users.builder().password(bCryptPasswordEncoder.encode(userCreateRequest.getPassWord())).email(userCreateRequest.getEmail()).userRole(userCreateRequest.getUserRole()).build());
return UserDTO.builder().id(users.getId()).password(users.getPassword()).userRole(users.getUserRole()).email(users.getEmail()).build();
}
비밀번호는 bCryptPasswordEncoder 를 통해서 인코딩해서 Users 테이블에 저장합니다.
@Override
protected void configure(HttpSecurity http) throws Exception {
...
.formLogin()
.loginPage("/login")
.successHandler(customLoginSuccessHandler())
.failureForwardUrl("/fail")
...
@Bean
public CustomLoginSuccessHandler customLoginSuccessHandler() {
return new CustomLoginSuccessHandler();
}
WebSecurityConfig.java 에서 로그인 관련해서 설정해주는 부분입니다.
loginPage 로 로그인 페이지 URL 을 설정해주고 SuccessHandler 로 성공했을 때 호출될 Handler 을 failureForwardUrl 로 로그인 실패했을 때 이동할 URL 을 설정해줍니다 !
CustomLoginSuccessHandler.java
public class CustomLoginSuccessHandler extends SavedRequestAwareAuthenticationSuccessHandler {
@Override
public void onAuthenticationSuccess(HttpServletRequest request, HttpServletResponse response, Authentication authentication) throws IOException {
// 인증이 성공한 경우 아래 로직 수행
// SecurityContextHolder > SecurityContext 에 Authentication 을 설정
SecurityContextHolder.getContext().setAuthentication(authentication);
// / 페이지로 redirect 해준다.
response.sendRedirect("/my");
}
}
SavedRequestAwareAuthenticationSuccessHandler 를 상속받아서 인증 성공되었을 때 SecurityContext 에 Authentication 을 설정해주고 My 페이지로 리다이렉트 해줍니다.
만일 로그인 후 원래 이용하던 서비스 페이지로 가야한다면 session이나 캐시에 referrer 을 저장하고 해당 값을 꺼내서 리다이렉트 하도록 하면 됩니다.
CustomAuthenticationProvider
@Slf4j
@RequiredArgsConstructor
public class CustomAuthenticationProvider implements AuthenticationProvider {
private final UserDetailsService userDetailsService;
private final BCryptPasswordEncoder passwordEncoder;
@Override
public Authentication authenticate(Authentication authentication) throws AuthenticationException {
// DB 에서 사용자 정보가 실제로 유효한지 확인 후 인증된 Authentication 리턴
UsernamePasswordAuthenticationToken token = (UsernamePasswordAuthenticationToken) authentication; // AuthenticaionFilter에서 생성된 토큰으로부터 아이디와 비밀번호를 조회함
String userEmail = token.getName();
String userPassWord = (String) token.getCredentials(); // UserDetailsService를 통해 DB에서 아이디로 사용자 조회
Users users = (Users) userDetailsService.loadUserByUsername(userEmail);
if (passwordEncoder.matches(userPassWord, users.getPassword()) == false) {
throw new BadCredentialsException(users.getUsername() + " 비밀번호를 확인해주세요.");
}
return new UsernamePasswordAuthenticationToken(users, userPassWord, users.getAuthorities());
}
@Override
public boolean supports(Class<?> authentication) {
return authentication.equals(UsernamePasswordAuthenticationToken.class);
}
}
AuthenticationProvider 를 implement 받은 클래스에서 authenticate 메소드를 오버라이딩 해서 로그인 하려는 사용자 정보가 실제로 DB 에도 있는 값인지 검증합니다.
리턴 값은 Authentication 인데 현재 코드에서는 UsernamePasswordAuthenticationToken 을 리턴해주고 있는데 UsernamePasswordAuthenticationToken 가 AbstractAuthenticationToken 를 상속받고 AbstractAuthenticationToken 가 Authentication 를 implements 함으로 UsernamePasswordAuthenticationToken 가 Authentication 의 구현체이기 때문에 리턴해줄 수 있습니다.
UserDetailsServiceImpl.java
@RequiredArgsConstructor
@Service
class UserDetailsServiceImpl implements UserDetailsService {
private final UserRepository userRepository;
@Override
public Users loadUserByUsername(String userEmail) {
return Optional.ofNullable(userRepository.findByEmail(userEmail)).orElseThrow(() -> new BadCredentialsException("이메일 주소를 확인해주세요."));
}
}
UserRepository 에서 실질적으로 데이터 있는지 없는지 여부 확인합니다.
ViewController.java
@RequestMapping("/my")
ModelAndView myView(Authentication authentication) {
UserDTO userDTO = Optional.ofNullable(userRepository.findByEmail(authentication.getName()))
.map(u -> UserDTO.builder().id(u.getId()).email(u.getEmail()).password(u.getPassword()).userRole(u.getUserRole()).build())
.orElseThrow(() -> new IllegalArgumentException("유저를 찾을 수 없습니다."));
ModelAndView modelAndView = new ModelAndView("/my");
modelAndView.addObject("userDTO", userDTO);
return modelAndView;
}
Authentication 객체를 통해 현재 로그인한 사람의 정보를 가져와 DB 에 검색해주고, ModelAndView 객체에 userDTO 정보를 담아서 화면에 넘겨줍니다
my.html
<!DOCTYPE html>
<html xmlns:th="http://www.thymeleaf.org">
<link href="https://cdn.jsdelivr.net/npm/bootstrap@5.0.2/dist/css/bootstrap.min.css" rel="stylesheet"
integrity="sha384-EVSTQN3/azprG1Anm3QDgpJLIm9Nao0Yz1ztcQTwFspd3yD65VohhpuuCOmLASjC" crossorigin="anonymous">
<head>
<meta charset="UTF-8">
<title>마이페이지</title>
</head>
<body>
<div class="container pt-5">
<div class="card mb-3 text-center mx-auto">
<div class="card-body">
<div class="container pt-2 text-center" th:object="${userDTO}">
<h5><span th:text="${userDTO.email}">회원</span>님 안녕하세요</h5>
<p class="mt-3">권한 : <span th:text="${userDTO.userRole}">회원</span></p>
<form id="logout" action="/logout" method="POST">
<input class="btn btn-primary" type="submit" value="로그아웃">
</form>
<td th:if="${userDTO.userRole.toString().equals('ADMIN')}">
<p class="text-center mt-4"><a href="/admin" class="link-secondary">회원 정보 보러가기</a></p>
</td>
</div>
</div>
</div>
</div>
</body>
</html>
화면에서는 thymeleaf 문법을사용해서 권한이 ADMIN 인 경우에만 회원정보 보러가기 버튼을 활성화 시켜줍니다.
WebSecurityConfig.java 에서 로그아웃 관련 설정
@Override
protected void configure(HttpSecurity http) throws Exception {
...
.logout()
.logoutUrl("/logout");
POST /logout 을 호출하면 로그아웃 되도록 설정합니다.
logout 관련 메소드들은 아래 테이블을 참고해주세요.
메소드 | 설명 |
---|---|
.logout() | logout 관련 설정을 진행할 수 있도록 돕는 LogoutConfigurer<> 을 리턴 |
.logoutUrl() | 사용자가 로그아웃을 요청하기 위한 URL 을 설정 |
.logoutSuccessUrl() | 로그아웃 후 redirect 될 url 설정 |
이렇게 기능 별 핵심 코드들에 대해서 설명이 끝났습니다 !
스프링 시큐리티에 대해서 모르다 보니깐 .. 많이 삽질도 했고, 아직 모르는 부분이 많지만 그래도 어느정도 잘 정리된 것 같아서 뿌듯하네요 🙂
전체 코드는 https://github.com/soyeon207/velog_example/tree/master/spring-security-server 를 참고 부탁드립니다. 감사합니다 🙇🏻♀️