본문 바로가기

Spring Boot

Spring Security + Spring Boot / Rest API Login 구현

목차

1. 개요

2. 개발환경

3. 주요 클래스

4. Config 설정

5. 동작 흐름 

6. 동작 결과

7. 참조


개요

 

Rest API 를 적용한 상태에서 JWT 방식에서 Session 방식으로 인증, 인가를 변경해야 했고, 

 

스프링 시큐리티의 기본 설정인 Form 인증 방식을 Rest API 에 맞게 변경했던 방법을 공유하고자

 

포스팅을 하게 됐습니다.

 


개발환경

 

JDK 17

 

Spring Boot 3.1.3

 

Spring Security 6.1.3

 

DB Postgresql

 

JPA

 

테스트 

 - Postman


주요 클래스

 

필터 관련 클래스

 

1. CustomAuthenticationFilter 

 

import lombok.Data;
import com.fasterxml.jackson.databind.ObjectMapper;
import jakarta.servlet.ServletException;
import jakarta.servlet.http.HttpServletRequest;
import jakarta.servlet.http.HttpServletResponse;
import org.springframework.security.core.Authentication;
import org.springframework.security.core.AuthenticationException;
import org.springframework.security.web.authentication.AbstractAuthenticationProcessingFilter;
import org.springframework.security.web.util.matcher.AntPathRequestMatcher;
import org.springframework.util.StringUtils;

import java.io.IOException;

public class CustomAuthenticationFilter extends AbstractAuthenticationProcessingFilter {

    private ObjectMapper objectMapper = new ObjectMapper();

    public CustomAuthenticationFilter() {
        // url과 일치할 경우 해당 필터가 동작합니다.
        super(new AntPathRequestMatcher("/api/login"));
    }
    @Override
    public Authentication attemptAuthentication(HttpServletRequest request, HttpServletResponse response) 
            throws AuthenticationException, IOException, ServletException {

        // 해당 요청이 POST 인지 확인
        if(!isPost(request)) {
            throw new IllegalStateException("Authentication is not supported");
        }

        // POST 이면 body 를 AccountDto( 로그인 정보 DTO ) 에 매핑
        AccountDto accountDto = objectMapper.readValue(request.getReader(), AccountDto.class);

        // ID, PASSWORD 가 있는지 확인
        if(!StringUtils.hasLength(accountDto.getUsername()) 
                || !StringUtils.hasLength(accountDto.getPassword())) {
            throw new IllegalArgumentException("username or password is empty");
        }

        // 처음에는 인증 되지 않은 토큰 생성
        CustomAuthenticationToken token = new CustomAuthenticationToken(
                accountDto.getUsername(),
                accountDto.getPassword()
        );

        // Manager 에게 인증 처리 
        Authentication authenticate = getAuthenticationManager().authenticate(token);

        return authenticate;
    }

    private boolean isPost(HttpServletRequest request) {

        if("POST".equals(request.getMethod())) {
            return true;
        }

        return false;
    }

    @Data
    public static class AccountDto {
        private String username;
        private String password;
    }

}

 

스프링 시큐리티는 기본적으로 Form 인증 방식을 채택하여 사용하고 있습니다.

 

그렇기 떄문에 Json 데이터를 매핑하기 위해 해당 필터를 커스텀하여 사용하고 있습니다.

 

Json Key 값은 기본적으로 usernamepassword 입니다.

 

Security 설정에서 해당 key 값을 변경할 수 있습니다.

 

 

2. CustomUserDetail

 

@Data
@Entity
@Builder
@NoArgsConstructor
@AllArgsConstructor
@Table(name = "tn_user")
public class UserEntity implements UserDetails {

    @Id
    private String username;

    @Column
    private String password;

    @Column
    private String name;

    @Column
    private String role;

    @Override
    public Collection<? extends GrantedAuthority> getAuthorities() {

        List<GrantedAuthority> roles = new ArrayList<>();
        roles.add(new SimpleGrantedAuthority(role));

        return roles;
    }

    @Override
    public String getPassword() {
        return password;
    }

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

    @Override
    public boolean isAccountNonExpired() {
        return true;
    }

    @Override
    public boolean isAccountNonLocked() {
        return true;
    }

    @Override
    public boolean isCredentialsNonExpired() {
        return true;
    }

    @Override
    public boolean isEnabled() {
        return true;
    }
}

 

UserDetail 을 구현한 UserEntity 입니다.

 

UserDetail 을 Entity와 합쳐도되고 따로 분리히서도 상관없습니다.

 

 

3. CustomUserDetailService

 

import lombok.RequiredArgsConstructor;
import org.springframework.security.core.userdetails.UserDetails;
import org.springframework.security.core.userdetails.UserDetailsService;
import org.springframework.security.core.userdetails.UsernameNotFoundException;
import org.springframework.stereotype.Service;

@Service
@RequiredArgsConstructor
public class CustomUserDetailsService implements UserDetailsService {

    // JPA 사용, Mybatis 사용시 mapper를 등록하셔서 user 정보를 받아오시면 됩니다.
    private final UserRepository userRepository;
    
    @Override
    public UserDetails loadUserByUsername(String username) throws UsernameNotFoundException {

        UserEntity entity = userRepository.findUserEntityByUsername(username)
                .orElseThrow(() -> new UsernameNotFoundException("username not found"));

        return entity;
    }
}

 

 

4. CustomAuthenticationToken

 

import org.springframework.security.authentication.AbstractAuthenticationToken;
import org.springframework.security.authentication.UsernamePasswordAuthenticationToken;
import org.springframework.security.core.GrantedAuthority;
import org.springframework.security.core.SpringSecurityCoreVersion;
import org.springframework.util.Assert;

import java.util.Collection;

public class CustomAuthenticationToken extends AbstractAuthenticationToken {

    private static final long serialVersionUID = SpringSecurityCoreVersion.SERIAL_VERSION_UID;

    private final Object principal;

    private Object credentials;

    
    // 인증 전 생성자
    public CustomAuthenticationToken(Object principal, Object credentials) {
        super(null);
        this.principal = principal;
        this.credentials = credentials;
        setAuthenticated(false);
    }

    // 인증 후 생성자
    public CustomAuthenticationToken(Object principal, Object credentials,
                                     Collection<? extends GrantedAuthority> authorities) {
        super(authorities);
        this.principal = principal;
        this.credentials = credentials;
        super.setAuthenticated(true);
    }

    @Override
    public Object getCredentials() {
        return this.credentials;
    }

    @Override
    public Object getPrincipal() {
        return this.principal;
    }

    @Override
    public void setAuthenticated(boolean isAuthenticated) throws IllegalArgumentException {
        Assert.isTrue(!isAuthenticated,
                "Cannot set this token to trusted - use constructor which takes a GrantedAuthority list instead");
        super.setAuthenticated(false);
    }

    @Override
    public void eraseCredentials() {
        super.eraseCredentials();
        this.credentials = null;
    }
    
}

 

 

해당 클래스는 스프링 시큐리티에서 인증 토큰으로 사용하는 AbstractAuthenticationToken 을 상속받아 만든

 

클래스입니다. 코드는  AbstractAuthenticationToken  와 같습니다.

 

 

5. CustomAuthenticationProvider

import lombok.RequiredArgsConstructor;
import org.springframework.security.authentication.AuthenticationProvider;
import org.springframework.security.authentication.BadCredentialsException;
import org.springframework.security.core.Authentication;
import org.springframework.security.core.AuthenticationException;
import org.springframework.security.core.userdetails.UserDetailsService;
import org.springframework.security.crypto.password.PasswordEncoder;
import org.springframework.stereotype.Component;

@Component
@RequiredArgsConstructor
public class CustomAuthenticationProvider implements AuthenticationProvider {

    private final UserDetailsService userDetailsService;
    private final PasswordEncoder passwordEncoder;

    @Override
    public Authentication authenticate(Authentication authentication) throws AuthenticationException {

        String loginId = authentication.getName();
        String password = (String) authentication.getCredentials();

        UserEntity entity = (UserEntity) userDetailsService.loadUserByUsername(loginId);

        if(!passwordEncoder.matches(password, entity.getPassword())) {
            throw new BadCredentialsException("Invalid Password");
        }

        return new CustomAuthenticationToken(entity, null, entity.getAuthorities());
    }

    @Override
    public boolean supports(Class<?> authentication) {
        return authentication.equals(CustomAuthenticationToken.class);
    }
}

 

위에서 구현한

 

CustomDetailUserService 와 UserEntity 를 사용하여 구현한 클래스 입니다.

 

username 과 password 를 확인하여 토큰을 생성하는 역할을 합니다.

 

 

핸들러 관련 클래스

 

인증 관련

1. CustomAuthenticationSuccessHandler

 

import com.fasterxml.jackson.databind.ObjectMapper;
import jakarta.servlet.http.HttpServletRequest;
import jakarta.servlet.http.HttpServletResponse;
import lombok.extern.slf4j.Slf4j;
import org.springframework.http.HttpStatus;
import org.springframework.http.MediaType;
import org.springframework.security.core.Authentication;
import org.springframework.security.web.authentication.AuthenticationSuccessHandler;
import org.springframework.stereotype.Component;

import java.io.IOException;

@Slf4j
@Component
public class CustomAuthenticationSuccessHandler implements AuthenticationSuccessHandler {

    private ObjectMapper objectMapper = new ObjectMapper();

    @Override
    public void onAuthenticationSuccess(HttpServletRequest request, 
                                        HttpServletResponse response,
                                        Authentication authentication) throws IOException {

        UserEntity user =  (UserEntity)authentication.getPrincipal();

        response.setStatus(HttpStatus.OK.value());
        response.setContentType(MediaType.APPLICATION_JSON_VALUE);

        objectMapper.writeValue(response.getWriter(), user);
    }
}

 

인증 성공 Handler 입니다. Rest API 이기 때문에 redirect 처리를 하지 않고 response 에 바로 값을 넣어줍니다.

 

 

2. CustomAuthenticationFailureHandler

 

import com.fasterxml.jackson.databind.ObjectMapper;
import jakarta.servlet.http.HttpServletRequest;
import jakarta.servlet.http.HttpServletResponse;
import lombok.extern.slf4j.Slf4j;
import org.springframework.http.HttpStatus;
import org.springframework.http.MediaType;
import org.springframework.security.authentication.BadCredentialsException;
import org.springframework.security.authentication.CredentialsExpiredException;
import org.springframework.security.authentication.DisabledException;
import org.springframework.security.core.AuthenticationException;
import org.springframework.security.web.authentication.AuthenticationFailureHandler;
import org.springframework.stereotype.Component;

import java.io.IOException;

@Slf4j
@Component
public class CustomAuthenticationFailureHandler implements AuthenticationFailureHandler {


    private ObjectMapper objectMapper = new ObjectMapper();

    @Override
    public void onAuthenticationFailure(HttpServletRequest request, 
                                        HttpServletResponse response, 
                                        AuthenticationException exception) throws IOException {

        String errMsg = "Invalid Username or Password";

        response.setStatus(HttpStatus.UNAUTHORIZED.value());
        response.setContentType(MediaType.APPLICATION_JSON_VALUE);

        if(exception instanceof BadCredentialsException) {
            errMsg = "Invalid Username or Password";
        } else if(exception instanceof DisabledException) {
            errMsg = "Locked";
        } else if(exception instanceof CredentialsExpiredException) {
            errMsg = "Expired password";
        }

        objectMapper.writeValue(response.getWriter(), errMsg);
    }
}

 

인증 실패 Handler 입니다.

 

인증과정에서 발생한 예외에 맞게 에러 메세지를 만들어 body에 담아서 보냅니다.

 

인가 관련

 

1. CustomLoginAuthenticationEntrypoint

 

import jakarta.servlet.http.HttpServletRequest;
import jakarta.servlet.http.HttpServletResponse;
import org.springframework.security.core.AuthenticationException;
import org.springframework.security.web.AuthenticationEntryPoint;
import org.springframework.stereotype.Component;

import java.io.IOException;

@Component
public class CustomLoginAuthenticationEntryPoint implements AuthenticationEntryPoint {
    @Override
    public void commence(HttpServletRequest request, 
                         HttpServletResponse response, 
                         AuthenticationException authException) throws IOException {
        response.sendError(HttpServletResponse.SC_UNAUTHORIZED, "UnAuthorized");
    }
}

 

권한이 없을경우 해당 EntryPoint 를 탑니다.

 

 

2. CustomAccessDeniedHander

 

import jakarta.servlet.http.HttpServletRequest;
import jakarta.servlet.http.HttpServletResponse;
import org.springframework.security.access.AccessDeniedException;
import org.springframework.security.web.access.AccessDeniedHandler;
import org.springframework.stereotype.Component;

import java.io.IOException;

@Component
public class CustomAccessDeniedHandler implements AccessDeniedHandler {
    @Override
    public void handle(HttpServletRequest request, HttpServletResponse response,
                       AccessDeniedException accessDeniedException) throws IOException {
        response.sendError(HttpServletResponse.SC_FORBIDDEN, "Access is denied");
    }

}

 

요청 거부 당했을 경우 해당 Handler 를 탑니다.

 

 

DB  정보

 

password 는 BCryptPasswordEncoder 를 사용하였습니다.

 


Config 설정

 

config 설정은 스프링 시큐리티 버전마다 다릅니다.

 

해당 버전에 맞게 조각만 맞춰주시면 됩니다.

 

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.configuration.AuthenticationConfiguration;
import org.springframework.security.config.annotation.web.builders.HttpSecurity;
import org.springframework.security.config.annotation.web.configuration.EnableWebSecurity;
import org.springframework.security.config.annotation.web.configurers.AbstractHttpConfigurer;
import org.springframework.security.crypto.bcrypt.BCryptPasswordEncoder;
import org.springframework.security.crypto.password.PasswordEncoder;
import org.springframework.security.web.SecurityFilterChain;
import org.springframework.security.web.authentication.UsernamePasswordAuthenticationFilter;
import org.springframework.security.web.context.DelegatingSecurityContextRepository;
import org.springframework.security.web.context.HttpSessionSecurityContextRepository;
import org.springframework.security.web.context.RequestAttributeSecurityContextRepository;

@Configuration
@EnableWebSecurity
@RequiredArgsConstructor
public class SecurityConfig{

    private final CustomAuthenticationSuccessHandler customAuthenticationSuccessHandler;
    private final CustomAuthenticationFailureHandler customAuthenticationFailureHandler;
    private final CustomLoginAuthenticationEntryPoint authenticationEntryPoint;
    private final AuthenticationConfiguration authenticationConfiguration;
    private final CustomAccessDeniedHandler accessDeniedHandler;

    @Bean
    public PasswordEncoder passwordEncoder(){
        return new BCryptPasswordEncoder();
    }

    @Bean
    public SecurityFilterChain securityFilterChain(HttpSecurity http) throws Exception {

        http
                .csrf(AbstractHttpConfigurer::disable)
                .cors(AbstractHttpConfigurer::disable)
                .authorizeHttpRequests(request -> request
                        .requestMatchers("/api/**").authenticated()
                        .anyRequest().permitAll())
                .addFilterBefore(ajaxAuthenticationFilter(), UsernamePasswordAuthenticationFilter.class)
                .exceptionHandling(config -> config
                        .authenticationEntryPoint(authenticationEntryPoint)
                        .accessDeniedHandler(accessDeniedHandler));

        return http.build();
    }

    @Bean
    public CustomAuthenticationFilter ajaxAuthenticationFilter() throws Exception {
        CustomAuthenticationFilter customAuthenticationFilter = new CustomAuthenticationFilter();
        customAuthenticationFilter.setAuthenticationManager(authenticationManager());
        customAuthenticationFilter.setAuthenticationSuccessHandler(customAuthenticationSuccessHandler);
        customAuthenticationFilter.setAuthenticationFailureHandler(customAuthenticationFailureHandler);
       
        // **
        customAuthenticationFilter.setSecurityContextRepository(
                new DelegatingSecurityContextRepository(
                        new RequestAttributeSecurityContextRepository(),
                        new HttpSessionSecurityContextRepository()
                ));

        return customAuthenticationFilter;
    }

    @Bean
    public AuthenticationManager authenticationManager() throws Exception {
        return authenticationConfiguration.getAuthenticationManager();
    }

}

 

 

** 해당 부분은 Rest API 방식을 사용하기 위해 추가해줍니다.

Form Login 방식은 기본적으로 DelegatingSecurityContextRepository 가 설정되어있지만, Custom 한 필터에는 설정되어있지 않고 RequestAttributeSecurity 가 설정되어있습니다.

 

RequestAttributeSecurity 는 세션을 저장 하지 않기 때문에 로그인 후 API 요청시 Anonymous 토큰이 생성되게 됩니다.

 


동작 흐름

동작 흐름은 다음과 같습니다.

 

1. CustomAuthenticationFilter 에서 설정한 URL 로 사용자가 요청을 하면 해당 필터가 요청을 가로챕니다.

 

2. 여기서 인증되지 않은 CustomAuthenticationToken 을 생성하고, AuthenticationManager 에게 인증처리를

    요청합니다. 여기서 Token 에 사용자가 건낸 아이디와 패스워드를 보관합니다.

 

3. AuthenticationManger 는 우리가 AuthenticationProvider 에게 인증처리를 건냅니다.

  여기서 우리가 만든 CustomAuthenticationProvider 가 동작합니다.

 

4. CustomAuthenticationProvider 에서 CustomUserDetailsService 로 DB 에 있는 User 정보를 가져와  

    AuthenticationToken 에 저장한 사용자 정보가 일치하는지 확인하여 일치하면 SuccessHandler 를 실패     하면 FailedHandler 를 실행합니다. 여기까지 과정중에 에러가 발생하면 FailedHandler 로 가게됩니다.

 

5. 인증 성공 후 기본 로직에 의해 인가 확인을 합니다.

   ( url을 등록하고 hasRole("USER") 와 같이 설정하였을때, 인가 확인을 합니다. )

 

6. 인가 실패시 경우에 따라 해당 Handler 를 실행합니다.

    CustomLoginAuthenticationEntrypoint 

    CustomAccessDeniedHander 

 

   인증 성공시 거치지 않음.


실행 결과 

 

로그인 정보

아이디    test

비밀번호 test

 

PostMan 사용

 

1. 잘못된 아이디 또는 비밀번호 입력시

 

 

2. 로그인 성공

 

 

3. 권한 없는 URL 요청 시

 


참조

 

인프런 - 정수원님 스프링시큐리티 강의