[실습편] 스프링 시큐리티 적용하기

mallin·2022년 2월 3일
4

스프링 시큐리티

목록 보기
2/2
post-thumbnail

스프링 시큐리티에 대해서 더 알고 싶은 분은 이론편을 먼저 참고해주세요 ❗️
➡️ [이론편] 스프링 시큐리티란 ?

구현된 화면

구현한 화면은 회원가입, 로그인, 마이, 관리자, 로그아웃으로 총 5개 이고,

화면누구나회원(USER)관리자(ADMIN)
회원가입, 로그인OOO
마이, 로그아웃XOO
관리자XXO

위와 같이 권한에 따라 접속 가능한 화면이 다릅니다

구현된 화면은 아래 움짤을 확인주세요 ⬇️

간단한 플로우는 다음과 같습니다.
1. 로그인을 실패하는 경우 실패 화면(/fail) 로 이동하고
2. 회원가입 성공 -> 로그인 성공 -> 마이페이지 로 이동하며
3. 마이페이지에서는 권한에 따라 admin 화면으로 이동하거나 이동하지 못합니다.

0. 사전 세팅

사용할 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-jpaJPA 를 사용하기 위한 라이브러리
spring-boot-starter-web웹 API 라이브러리
spring-boot-starter-securityspring security 라이브러리
spring-boot-starter-thymeleafview 템플릿은 thymeleaf를 사용
org.projectlombok:lombok어노테이션 기반으로 코드를 자동완성 해주는 라이브러리
com.h2database:h2h2 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) {
       // 커스텀한 AuthenticationProviderAuthenticationManager 에 등록 
       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 에 데이터가 정상적으로 있는지 없는지 여부를 확인해줍니다.

다른 자세한 설명을 주석을 참고해주세요.

1. 회원가입


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 테이블에 저장합니다.

2. 로그인

    @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 에서 실질적으로 데이터 있는지 없는지 여부 확인합니다.

3. 메인 화면


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 인 경우에만 회원정보 보러가기 버튼을 활성화 시켜줍니다.

4. 로그아웃

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 를 참고 부탁드립니다. 감사합니다 🙇🏻‍♀️

0개의 댓글