본문 바로가기
감자-갱생-프로젝트

Spring Security는 사드세요... 제발 - Authentication 편

by kriorsen 2025. 4. 25.

📌  인간은 망각의 어쩌구


아키텍처 기억 되살리기

 

🪆  Spring Security는 마트료시카다


 

Architecture 다 보고 다음 항목인 Authentication을 펼쳤는데

아키텍처의 아키텍처의 아키텍처의 아키텍처의 아키텍처

 

📌   Authentication Architecture


🚨 SecurityContextHolder

안쪽부터 살펴보면 Principal, Credentails, Authorities와 같은 인증 정보를 담고 있는 Authentication 객체가 있다. 이 객체는 인증 요청 시 AuthenticationManager에 전달되는 입력값이다.

 

왜 Authentication을 직접 SecurityContextHolder에 담지 않고 SecurityContext에 감싸놓은 건지 의문이 들어서 SecurityContext 문서를 읽어 봤는데

 

minimun security information이라고 표현한 것을 봐서는 확장성을 고려해 이렇게 설계한 것 같다 🤔

 

SecurityContext는 SecurityContextHolder에 저장되며, SecurityContextHolder는 다수의 사용자가 동시에 요청을 보낼 때, 서로의 인증 정보가 섞이지 않도록 ThreadLocal을 이용해 SecurityContext를 각 스레드별로 유지한다.  

 

SecurityContextHolder의 코드를 보면 SecurityContextHodlerStrategy 클래스인 필드를 가지는데 strategy 초기화 로직에서 TREADLOCAL 모드를 기본으로 설정하고 있다.

 

 

기본 모드에서 사용하는 ThreadLocalSecurityContextHolderStrategy 구현체가 스레드별로 SecurityContext를 저장한다.

 

public class SecurityContextHolderFilter extends GenericFilterBean {
	private void doFilter(HttpServletRequest request, HttpServletResponse response, FilterChain chain)
			throws ServletException, IOException {
		// ...
		finally {
			this.securityContextHolderStrategy.clearContext();
			request.removeAttribute(FILTER_APPLIED);
		}
	}

}

 

애플리케이션을 실행하는 동안 스레드가 재사용될 수 있기 때문에, 해당 요청이 끝난 뒤에는 SecurityContextHolderFilter가 clearContext를 호출해 안전하게 인증 정보를 삭제해 준다. 

 

🚨 AuthenticationManager

SecurityContextHolder에 포함된 요소들의 주된 역할은 인증 정보를 저장하는 것이고, AuthenticationManager는 Spring Security 필터들이 인증을 수행하는 방식을 정의하는 것이 목적이다. AuthenticationManager가 인증을 수행하면, Spring Security의 필터들이 반환된 Authentication 객체를 SecurityContextHolder에 설정한다.

public class ProviderManager implements AuthenticationManager, MessageSourceAware, InitializingBean {
	
    @Override
	public Authentication authenticate(Authentication authentication) throws AuthenticationException {
		for (AuthenticationProvider provider : getProviders()) {
			result = provider.authenticate(authentication);
			if (result != null) {
				copyDetails(authentication, result);
				break;	
			}
		}
	}
}

 

ProviderManager는 AuthenticationManager의 구현체로 인증 처리를 위힘하는 역할을 한다. 실제 코드에서 authenticate 메소드의 핵심 부분만 보면 등록된 AuthenticationProvider 목록을 순회하며 인증을 시도하고, 생성된 인증 정보를 반환한다.

 

🔁 인증 흐름

 

📌  Authentication 구현하기


🥸 Username/Password 방식

사용자 이름과 비밀번호를 입력하는 건 가장 기본적인 인증 방식이다. Spring Security에는 사용자 인증 정보를 조회하는 UserDetailsService라는 인터페이스가 있다. DB 기반으로 사용자가 입력한 username에 해당하는 사용자의 비밀번호, 권한 등의 정보를 불러오기 위해서는 직접 해당 인터페이스를 구현해야 한다.

public class AuthenticationConfiguration {
	static class DefaultPasswordEncoderAuthenticationManagerBuilder extends AuthenticationManagerBuilder {
    	@Override
		public <T extends UserDetailsService> DaoAuthenticationConfigurer<AuthenticationManagerBuilder, T> userDetailsService(
				T userDetailsService) throws Exception {
			return super.userDetailsService(userDetailsService).passwordEncoder(this.defaultPasswordEncoder);
		}

	}
}

public class AuthenticationManagerBuilder
		extends AbstractConfiguredSecurityBuilder<AuthenticationManager, AuthenticationManagerBuilder>
		implements ProviderManagerBuilder<AuthenticationManagerBuilder> {
	public <T extends UserDetailsService> DaoAuthenticationConfigurer<AuthenticationManagerBuilder, T> userDetailsService(
			T userDetailsService) throws Exception {
		this.defaultUserDetailsService = userDetailsService;
		return apply(new DaoAuthenticationConfigurer<>(userDetailsService));
	}
}

public abstract class AbstractDaoAuthenticationConfigurer<B extends ProviderManagerBuilder<B>, C extends AbstractDaoAuthenticationConfigurer<B, C, U>, U extends UserDetailsService>
		extends UserDetailsAwareConfigurer<B, U> {
	private DaoAuthenticationProvider provider = new DaoAuthenticationProvider();

	private final U userDetailsService;
    
    @Override
	public void configure(B builder) throws Exception {
		this.provider = postProcess(this.provider);
		builder.authenticationProvider(this.provider);
	}
}

 

AuthenticationManager를 명시적으로 설정하지 않았고, AuthenticationProvider도 직접 등록하지 않은 경우, Spring Security가 자동으로 UserDetailService를 찾아서 DaoAuthenticationProvider로 인증을 구성해 준다. 

 

DaoAuthenticationProvider는 내부적으로 UserDetailsService와 PasswordEncoder를 이용해서 사용자의 아이디/비밀번호 인증을 처리한다.

@Service
@RequiredArgsConstructor
public class CustomUserDetailsService implements UserDetailsService {

    private final UserRepository userRepository;

    @Override
    public UserDetails loadUserByUsername(String username) throws UsernameNotFoundException {
        return userRepository.findByEmail(username)
                .map(this::createUser)
                .orElseThrow(() -> new NexterviewException(NexterviewErrorCode.USER_NOT_FOUND));
    }

    private UserDetails createUser(com.nexterview.server.domain.User user) {
        return new CustomUserDetails(user.getId(), user.getEmail(), user.getPassword(),
                List.of(new SimpleGrantedAuthority(Role.USER.getRoleName())));
    }
}

 

UserDetailsService가 호출되기까지의 흐름은 복잡하지만, 유저 조회 기능은 뚝딱뚝딱 구현하기 쉽다.

@RestController
@RequestMapping("/api")
@RequiredArgsConstructor
public class AuthController {

    private final TokenProvider tokenProvider;
    private final AuthenticationManager authenticationManager;

    @PostMapping("/authenticate")
    public TokenDto authorize(@Valid @RequestBody LoginRequest request) {
        UsernamePasswordAuthenticationToken usernamePasswordAuthenticationToken = new UsernamePasswordAuthenticationToken(
                request.email(), request.password());
        Authentication authentication = authenticationManager.authenticate(usernamePasswordAuthenticationToken);
        String token = tokenProvider.generateToken(authentication);

        return new TokenDto(token);
    }
}

 

Spring Security는 기본적으로 Form 로그인 방식을 제공하며, UsernamePasswordAuthenticationFilter와 같은 내장 필터를 통해 자동으로 처리한다.

 

하지만 현재는 브라우저 form 전송 방식이 아닌, JSON 기반의 REST API 방식으로 로그인을 구현하고 있기 때문에,

AuthenticationManager.authenticate()를 직접 호출하는 커스텀 컨트롤러를 사용한다.

 

해당 컨트롤러는 최초 로그인 요청에 대해 인증을 수행하고 JWT를 발급하여 클라이언트에게 응답한다.

로그인 이후에는 클라이언트가 매 요청마다 토큰을 전송하고, 이후 인증은 JWT 관련 필터에서 처리되기 때문에 Authentication을 SecurityContextHolder에 저장하는 로직을 생략했다.

 

 

아무튼 이렇게 영차영차 구현하면 드디어 토큰을 발급받을 수 있다.

🤧 JWT 방식

로그인을 마친 사용자는 헤더에 토큰 정보를 포함해서 서버에게 요청을 전송할 테니, 토큰을 통한 인증을 구현해야 한다.

@RequiredArgsConstructor
public class JwtFilter extends OncePerRequestFilter {

    private final TokenProvider tokenProvider;

    @Override
    protected void doFilterInternal(HttpServletRequest request, HttpServletResponse response, FilterChain chain)
            throws ServletException, IOException {
        String jwt = resolveToken(request);
        if (StringUtils.hasText(jwt) && tokenProvider != null) {
            Authentication authentication = tokenProvider.getAuthentication(jwt);
            SecurityContextHolder.getContext().setAuthentication(authentication);
        }

        chain.doFilter(request, response);
    }

    private String resolveToken(HttpServletRequest request) {
        String bearerToken = request.getHeader(AUTHORIZATION_HEADER);
        if (StringUtils.hasText(bearerToken) && bearerToken.startsWith(BEARER_PREFIX)) {
            return bearerToken.substring(7);
        }
        return null;
    }
}

 

JwtFilter는 헤더에서 토큰을 추출하고, 이를 기반으로 인증 정보를 생성하여 SecurityContextHolder에 저장하는 역할을 수행한다. 이 인증 정보는 이후 애플리케이션의 다른 부분에서 사용할 수 있게 된다.

 

OncePerRequestFilter를 상속하여, 하나의 요청에 대해 필터가 한 번만 실행되도록 보장하며, 마지막에서 chain.doFilter(request, response)를 호출하여 다음 필터로 요청과 응답을 전달한다.

 

	@Bean
	public SecurityFilterChain securityFilterChain(HttpSecurity http) throws Exception {
 		return http
			// ...
			.addFilterBefore(new JwtFilter(tokenProvider), UsernamePasswordAuthenticationFilter.class)
			.build();
	}

 

폼 로그인 필터인 UsernamePasswordAuthenticationFilter 앞에 등록하면 JWT 인증을 먼저 처리하고, 인증된 사용자의 정보를 이후 필터들이 사용할 수 있게 된다.

 

 

 

 

📌 끗...?


끝까지 봐주셨으니 깨알 자랑 하나 하자면...

저희 앵두 이제 손 할 줄 압니다 🍒