본문 바로가기
web/SpringBoot

[SpringBoot] OAuth2.1 모듈로 인증 서버 만들기 - 2

by 뽀리님 2024. 8. 30.

저번시간에 이어 ID/PW로 인증 뒤 인가코드를 가져오는거 까지 구현해보자.

 

 

✔️ Login endpoint 설정

/**
 * custom filter setting
 * @param authenticationManager
 * @return
 */
public AbstractAuthenticationProcessingFilter abstractAuthenticationProcessingFilter(AuthenticationManager authenticationManager) {
    LoginAuthenticationFilter filter = new LoginAuthenticationFilter("/api/login", authenticationManager); // login api endpoint
    return filter;
}

 

시큐리티에 .formLogin() 옵션으로 설정해서 진행해도 되지만, 나는 로그인페이지는 각 서비스마다 따로 있고

REST API 로 로그인을 구현하기 위해 필터에서 따로 설정했다.

 

 

1. AuthenticationManager를 먼저 설정해준다

// AuthenticationManager custom setting
AuthenticationManagerBuilder sharedObject = httpSecurity.getSharedObject(AuthenticationManagerBuilder.class); // 사용자정의 인증로직 세팅
sharedObject.userDetailsService(customUserDetailService).passwordEncoder(new BCryptPasswordEncoder()); // userDetailService(id/pw) set
AuthenticationManager authenticationManager = sharedObject.build();
httpSecurity.authenticationManager(authenticationManager);  // 생성된 AuthenticationManager 설정

 

Spring Security 필터 체인이 초기화되는 과정에서 호출되도록 설정하였다.

 

 


2. LoginAuthenticationFilter  구현

@Slf4j
public class LoginAuthenticationFilter extends AbstractAuthenticationProcessingFilter {

    public LoginAuthenticationFilter(final String defaultFilterProcessesUrl,
                                     final AuthenticationManager authenticationManager) {
        super(defaultFilterProcessesUrl, authenticationManager);
    }

    /**
     * 자격증명 추출 후 AuthenticationManager 에게 인증 요청
     * @param request
     * @param response
     * @return
     * @throws AuthenticationException
     * @throws IOException
     */
    @Override
    public Authentication attemptAuthentication(HttpServletRequest request,
                                                HttpServletResponse response)
            throws AuthenticationException, IOException {

        if (!"POST".equalsIgnoreCase(request.getMethod())) {
            log.error("Must be requested POST");
            throw new AuthenticationServiceException("Authentication method not supported: " + request.getMethod());
        }

        // JSON 요청의 바디에서 로그인 정보를 읽어옴
        ServletInputStream inputStream = request.getInputStream();
        AuthDto authDto = ConvertUtils.streamToJson(inputStream,AuthDto.class);
        
        // UsernamePasswordAuthenticationToken을 생성하여 AuthenticationManager에 전달
        UsernamePasswordAuthenticationToken authRequest =
                new UsernamePasswordAuthenticationToken(authDto.getLoginId(), authDto.getPassword());
        SecurityContextHolder.getContext().setAuthentication(authRequest);
        return this.getAuthenticationManager().authenticate(authRequest);
    }

    @Override
    protected void successfulAuthentication(HttpServletRequest request, HttpServletResponse response, FilterChain chain, Authentication authResult) throws IOException, ServletException {
        // 인증 성공 후 로직
        String redirectUrl = "/oauth2/authorize?client_id=tyler&response_type=code&redirect_uri=http://localhost:8080/callback&scope=read%20write";
        response.sendRedirect(redirectUrl);
    }

    @Override
    protected void unsuccessfulAuthentication(HttpServletRequest request, HttpServletResponse response, AuthenticationException failed) throws IOException, ServletException {
        // 인증 실패 처리 로직
        response.setStatus(HttpServletResponse.SC_UNAUTHORIZED);
        response.getWriter().write("Authentication failed: " + failed.getMessage());
    }

 

AbstractAuthenticationProcessingFilter 클래스를 상속받아 LoginAuthenticationFilter 을 구현한다.

 

기존에는 해당 인터페이스를 여러 추상 인증필터 (ex. AbstractAuthenticationProcessingFilter) 에서 
사용하지 않고 모두 NullSecurityContextRepository 를 사용했었다.(5.x기준)
6.x 부터는 직접적으로(?) 사용한다.

 

 

  • attemptAuthentication() 

Method를 통해 요청에서 자격 증명을 추출하고, 이를 사용해 AuthenticationManager에게 인증을 요청한다.
예를 들어, 요청 본문에서 ID/PW 를 추출하거나, OAuth2 토큰을 추출하여 인증할 수 있다.

  • successfulAuthentication () 

인증이 성공한 후 호출. 
인증 객체를 SecurityContextHolder에 저장하고, 후속 처리를 수행한다.
주로 JWT 토큰 발급, 세션 생성, 리다이렉트 등의 작업을 할 수 있다.

  • unsuccessfulAuthentication() 

인증이 실패한 경우 호출. 
인증 실패에 대한 응답을 클라이언트에게 반환한다.

 

 

하지만 이렇게 만들고 oauth2 endpoint를 호출해보면 아래와 같이 뜰것이다.

2024-08-30T14:34:51.420+09:00  INFO 18288 --- [d-auth] [nio-8080-exec-3] c.d.t.s.CustomRegisteredClientRepository : == context:SecurityContextImpl [Null authentication]
2024-08-30T14:34:51.430+09:00 DEBUG 18288 --- [d-auth] [nio-8080-exec-3] o.s.s.w.a.AnonymousAuthenticationFilter  : Set SecurityContextHolder to anonymous SecurityContext



두둥... 😥😥

해당 부분 삽질을 엄청했더랬다. 나는 분명히 setAuthentication()를 해주고 있는데 왜 SecurityContextHolder를 못가져오지????



☑️  Security의 추가적인 변화

 

현재 작성중에 사용하는 버전은 6.x 버전이다.

참조 https://docs.spring.io/spring-security/reference/migration/servlet/session-management.html



기존에 Security 5는 SecurityContextPersistenceFilter를 사용하였고, 이때 매 요청마다 세션을 생성하였는데 ,
이는 정상적인 요청과 응답이 수행되기 전에도 세션을 생성한다는 문제점과, 이러한 세션의 추적이 어렵다는 문제가 있었다.

Spring Security 6는 위와 같은 문제를 해결하기 위해 
기본적인 동작은 SecurityContextHolderFilter를 사용하며 이는 SecurityContext를 SecurityContextRepository로 부터 읽기 동작만을 수행을 한다. 
또한 세션의 저장은 유저가 요청 시에 명시 할 경우에만 세션을 생성하도록 변경되었다.


핵심 로직의 주요 변화 포인트는 다음과 같다.

1. SecurityContext를 담는 ThreadLocal 의 타입 변화
2. SecurityContextRepository 인터페이스의 실제 활용 코드




번외로 가장 중요한 클래스는 SecurityContextRepository의 구현체인
DelegatingSecurityContextRepository 클래스다.

 

DelegatingSecurityContextRepository?
SecurityContext를 여러 SecurityContextRepository에 저장하고, 일련의 순서로 검색할 수 있다.

 


☑️ ThreadLocal의 변화

 

ThreadLocalSecurityContextHolderStrategy.class
5.x 코드

@Override
public SecurityContext getContext() {
    SecurityContext ctx = contextHolder.get();
    if (ctx == null) {
        ctx = createEmptyContext();
        contextHolder.set(ctx);
    }
    return ctx;
}

 

SecurityContext 객체를 ThreadLocal에 직접 저장하고 직접 접근한다.


6.x 코드

@Override
public SecurityContext getContext() {
    return getDeferredContext().get();
}

@Override
public Supplier<SecurityContext> getDeferredContext() {
    Supplier<SecurityContext> result = contextHolder.get();
    if (result == null) {
        SecurityContext context = createEmptyContext();
        result = () -> context;
        contextHolder.set(result);
    }
    return result;
}

 

Supplier를 사용하여 필요에 따라 SecurityContext를 동적으로 생성한다.

 

 

 

추측건데 ServletRequest 객체에만 인증 객체가 들어갔기 때문에,
새롭게 변화된 ThreadLocalSecurityContextHolderStrategy 클래스에서 변수로 등록된 ThreadLocal 타입의 변수에 저장된 Supplier 를 뽑아 냈을 때,

최초로 들어간 () -> request.getSession(false) 의 값이 null 을 반환하게 되어 5.x버전에서의 동작과 다르게 AnonymousAuthenticaiton(익명사용자 객체)을 반환 받게 된 것이라고 볼 수 있다.


✅ 해결방안

 

3. LoginAuthenticationFilter Context 설정

 

SecurityContextRepository의 구현체인 DelegatingSecurityContextRepository 클래스를 
LoginAuthenticationFilter 에 추가하였다.

 

public LoginAuthenticationFilter(final String defaultFilterProcessesUrl,
                                 final AuthenticationManager authenticationManager) {
    super(defaultFilterProcessesUrl, authenticationManager);
    // 로그인 이후 Context 생성 전략 설정
    setSecurityContextRepository(
            new DelegatingSecurityContextRepository(
                    new HttpSessionSecurityContextRepository(),
                    new RequestAttributeSecurityContextRepository()
            )
    );
}




DelegatingSecurityContextRepository 에 대한 설명은 아래 공식문서에 잘나와있다.
https://docs.spring.io/spring-security/reference/servlet/authentication/persistence.html#delegatingsecuritycontextrepository

 

Persisting Authentication :: Spring Security

The first time a user requests a protected resource, they are prompted for credentials. One of the most common ways to prompt for credentials is to redirect the user to a log in page. A summarized HTTP exchange for an unauthenticated user requesting a prot

docs.spring.io

 

 

 

이렇게 하면 Context에 저장이 잘된다.

 

== context:SecurityContextImpl [Authentication=CustomClientAuthentication [Principal=org.springframework.security.core.userdetails.User [Username=test, Password=[PROTECTED], Enabled=true, AccountNonExpired=true, CredentialsNonExpired=true, AccountNonLocked=true, Granted Authorities=[ROLE_ADMIN]], Credentials=[PROTECTED], Authenticated=true, Details=null, Granted Authorities=[ROLE_ADMIN]]]

 

 

 

인가코드 발급받기

이제 인증을 저장했으니, 

인증 성공후 로직처리를 해보자

 

@Override
protected void successfulAuthentication(HttpServletRequest request, HttpServletResponse response, FilterChain chain, Authentication authResult) throws IOException, ServletException {

    // 인증 성공 후 로직
    String redirectUrl = "/oauth2/authorize?client_id=tyler&response_type=code&redirect_uri=http://localhost:8080/callback&scope=read%20write";
    response.sendRedirect(redirectUrl);
}

 

sendRedirect 동작 => HTTP 302 상태 코드를 클라이언트(브라우저)에게 전송하여, 브라우저가 지정된 url로 새로운 GET 요청을 보내도록 지시

 

하지만 이렇게 나오는 결과는..

2024-08-30T15:48:56.492+09:00 DEBUG 24276 --- [d-auth] [nio-8080-exec-5] o.s.s.a.dao.DaoAuthenticationProvider    : Authenticated user
2024-08-30T15:48:56.499+09:00 DEBUG 24276 --- [d-auth] [nio-8080-exec-9] o.s.security.web.FilterChainProxy        : Securing GET /oauth2/authorize?client_id=tyler&response_type=code&redirect_uri=http://localhost:8080/callback&scope=read%20write
2024-08-30T15:48:56.503+09:00  INFO 24276 --- [d-auth] [nio-8080-exec-9] c.d.t.s.CustomRegisteredClientRepository : == clientId:tyler
2024-08-30T15:48:56.503+09:00  INFO 24276 --- [d-auth] [nio-8080-exec-9] c.d.t.s.CustomRegisteredClientRepository : == context:SecurityContextImpl [Null authentication]
2024-08-30T15:48:56.513+09:00 DEBUG 24276 --- [d-auth] [nio-8080-exec-9] o.s.s.w.a.AnonymousAuthenticationFilter  : Set SecurityContextHolder to anonymous SecurityContext

 

 

 


으아아아 미추어 버리겠다....ㅜㅜㅜㅜㅜㅜㅜㅜㅜㅜㅜㅜㅜㅜㅜㅜ
왜왜왜 sendRedirect 했잖아.. 🤬🤬🤬🤬🤬

내가 생각했던 로직은
/oauth2/authorize 를 Redirect 하여 인가코드를 받아오는 로직이었다.
근데 또 또또또 anonymous SecurityContext 에러를 만났다.. 미칠꺼같다.

이걸로 한 3일넘게 삽질한 거 같다.

결국 해결은 했지! (・ิω・ิ)

 

 

 

4.  Filter 에 SuccessHandler 추가

/**
 * custom filter setting
 * @param authenticationManager
 * @return
 */
public AbstractAuthenticationProcessingFilter abstractAuthenticationProcessingFilter(AuthenticationManager authenticationManager) {
    LoginAuthenticationFilter filter = new LoginAuthenticationFilter("/api/login", authenticationManager); // login api endpoint
    filter.setAuthenticationSuccessHandler(customAuthenticationSuccessHandler); // success handler set
    return filter;
}

 

4-1. SuccessHandler 

@Component
@RequiredArgsConstructor
public class CustomAuthenticationSuccessHandler implements AuthenticationSuccessHandler {
    private final ClientService clientService;

    @Override
    public void onAuthenticationSuccess(HttpServletRequest request, HttpServletResponse response,
                                        Authentication authentication) throws IOException {
        log.info("======= success authentication:"+authentication);

        // 클라이언트 ID는 요청 파라미터나 다른 방식으로 가져올 수 있음
        ClientEntity client = clientService.getClientInfoFromHeader(request.getHeader(Constants.AUTH_HEADER));

        if (client != null) {
            // 인가 코드 요청 URL 생성
            String redirectUrl = clientService.buildAuthorizationUrl(Constants.AUTHORIZE_URL, client);

            // 클라이언트 정보를 포함한 CustomAuthenticationToken 생성
            CustomClientAuthentication customAuth = clientService.createClientAuthentication(authentication,client);

            // SecurityContextHolder에 새로운 Authentication 설정
            SecurityContextHolder.getContext().setAuthentication(customAuth);

            // 리다이렉션 처리
            response.sendRedirect(redirectUrl);
        } else {
            // 클라이언트가 없는 경우 에러 처리
            response.sendError(HttpServletResponse.SC_UNAUTHORIZED, "Invalid client ID");
        }
    }
}

 

 

 


✓  AuthenticationSuccessHandler  vs SuccessfulAuthentication


1. 필터에서 sendRedirect(url) 동작
필터는 HTTP 요청의 초기 단계에서 실행되며, 요청을 가로채어 리다이렉트 한다. 
필터에서 sendRedirect(url)를 호출하면, 현재 요청 처리가 중단되고 클라이언트에게 302 리다이렉트 응답이 전송된다.
따라서 필터는 이 요청을 처리하기 전에 중단시켜서, 단순히 클라이언트에게 리다이렉트 URL을 전달하는 역할을 한다.

2. 핸들러에서 sendRedirect(url) 동작
핸들러는 보통 필터 체인이 완료된 후에 특정 이벤트(예: 인증 성공, 실패, 로그아웃 등)에 반응하여 호출된다.
핸들러에서 sendRedirect(url)를 호출하면, 클라이언트는 브라우저에서 해당 URL로 새로운 GET 요청을 보낸다.
브라우저가 해당 URL(예: /oauth2/authorize)로 요청을 보낸 후, 서버는 이 요청에 대한 응답을 처리하고 결과를 클라이언트에게 돌려준다

필터에서 sendRedirect(url)을 사용하면, 클라이언트는 지정된 URL로 단순히 리다이렉트된다.
필터는 요청 초기 단계에서 이 작업을 수행하며, 이후의 서버 측 로직을 수행하지 않는다는 소리다.

핸들러는 보통 필터 체인이 모두 처리된 후, 보안 이벤트의 후속 작업으로 리다이렉트를 처리한다.
그런고로 필터에 SecurityContext가 다 설정된 후 후반부에 개입하기 때문에 인증객체를 가지고 Redirect가 가능하다.

 

 차이점

필터 체인 내에서 즉시 리다이렉트 필터 체인 이후에 리다이렉트
이후의 필터들이 실행되지 않음 필터 체인 완료 후 리다이렉트가 실행됨
이전 필터에서의 응답 처리 결과가 적용될 수 있음 모든 필터들의 작업이 완료된 후 리다이렉트가 발생함
서블릿 컨테이너에서 초기 요청 처리 단계에서 동작함 스프링 보안 이벤트 처리 후에 동작함

 

 

이렇게 설정하고 다시 수행해보자

 

 

 

인가코드를 잘받아온다!

 

-끝-

 

참고
https://bombo96.tistory.com/100
https://velog.io/@kide77/Security-6.1.x-Rest-%EB%A1%9C%EA%B7%B8%EC%9D%B8-%EC%84%B1%EA%B3%B5%EC%97%90-%EB%8C%80%ED%95%9C-%EA%B3%A0%EC%B0%B0-%EC%8A%A4%EC%95%95-%EC%A3%BC%EC%9D%98