OAuth2 준비하기

이제 다음 Spring Security 글부터 OAuth2에 대해서 글을 쓰려고합니다. 그 전에 소셜로그인을 하기위해서는 설정해야하는 부분이 있어서 글씁니다.

 

 

구글로그인

OAuth2 에서 구글 로그인을 구현해보려고 합니다. 아래 순서에 따라서 해봅시다.

 

 

 

 

1. Google Cloud 로그인

 

Google 클라우드 플랫폼

로그인 Google 클라우드 플랫폼으로 이동

accounts.google.com

구글로 로그인하고 가입합니다.

 

 

2. 프로젝트 생성

프로젝트 선택을 누르고 새 프로젝트를 클릭해서 프로젝트를 생성합니다.

 

3. OAuth 동의 화면 설정

 

 

왼쪽 상단에 메뉴를 클릭하고

API 및 서비스에서 OAuth 동의 화면 클릭합니다.

외부를 선택하고 만들기 눌러줍니다.

 

앱 등록 수정에서 데이터를 입력하고 저장 후 계속을 눌러줍니다.

 

3-1. 범위 설정

 

이메일과 프로필을 선택하고 넘어가줍니다.

 

3-2. 테스트 사용자 설정

테스트 사용자를 추가해줍니다.

 

 

3-3. 앱 게시

 

앱 게시를 눌러줍니다.

 

 

 

4. 사용자 인증정보 설정

 

OAuth 클라이언트 ID를 클릭해줍니다.

 

애플리케이션 유형을 선택하고 다음으로 넘어간다음 redirection url 설정해주고 만들기를 누릅니다.

 

클라이언트 ID와 클라이언트 보안 비밀번호를 따로 저장해둡시다.

 

 

개요

저번 글에서 SessionManagement 에 대해서 소개하는 글을 적었습니다. 공식문서에 있는 글을 최대한 줄이고 압축해서 글을 쓰려다보니 설명이 부족한 부분도 많았던 것 같습니다. 그래서 두 파트로 나누었고 이번 글에서는 SessionManagement를 어떤식으로 적용하는지 보여드리도록 하겠습니다.

 

 

 

1. 동시 로그인 차단하기

먼저 동시 세션을 제어해서 하나의 로그인한 허용해보도록 하겠습니다.

저는 최대 1개의 세션만 허용하고 새로운 로그인이 발생하면 이전 세션을 만료시키겠습니다. 그리고 세션이 만료가 되면 "/join?expired" 로 이동시키도록 제어해 보겠습니다.

 

 

UserDetails

세션을 1개로 제한한다는 것은 이전 세션과 비교해서 같은세션인지 확인하는 과정이 있어야 가능합니다. 이전 시간에 UserDetails를 구현한 PrincipalDetails 구현체가 있었습니다. 이 PrincipalDetails 의 equals와 hashCode를 override하겠습니다.

 

여기서 한가지 알아야할 것은 PrincipalDetails 객체 자체를 비교하는것은 중요하지 않습니다. 그 안에 있는 User를 비교하는 것이 더 중요합니다. User의 기본키는 교유하기 때문입니다.

 

먼저 User 객체의 equals와 hashCode를 override 하겠습니다.

 

@Getter @Setter
@EqualsAndHashCode(of = "username")
public class User {

    private String username;
    private String password;
    private String role;
    private String name;
}

저는 간단한 개발환경을 위해서 기본키값을 username으로 했지만 실제 DB를 사용하는 환경에서는 id값을 비교하면됩니다.

 

 

lombok을 사용하지 않을 경우

@Getter @Setter
public class User {

    private String username;
    private String password;
    private String role;
    private String name;

    @Override
    public boolean equals(Object o) {
        if (this == o) return true;
        if (o == null || getClass() != o.getClass()) return false;
        User user = (User) o;
        return Objects.equals(username, user.name);
    }

    @Override
    public int hashCode() {
        return Objects.hash(username, username);
    }
}

 

JPA가 기본적으로 id 기반으로 equals/hashCode 제공합니다. 복합키를 사용하거나 또는 명확성을 위해서 객체에서 override해주는 것도 좋은 방법이라고 생각합니다.

 

 

public record PrincipalDetails(User user) implements UserDetails {

    @Override
    public Collection<? extends GrantedAuthority> getAuthorities() {
        return List.of(new SimpleGrantedAuthority(user.getRole()));
    }

    @Override
    public String getPassword() {
        return user == null ? null : user.getPassword();
    }

    @Override
    public String getUsername() {
        return user == null ? null : user.getUsername();
    }

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

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

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

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

 

 

 

record의 특성을 이해하길 원하신다면 다음 글을 읽어보시길 추천드립니다.

 

 

[JAVA] Record와 Sealed

개요오늘은 Java 14부터 도입된 Record와 Java 15에서 preview로 시작된 Sealed Classes 에 대해서 알아보겠습니다.참고로 Sealed Classes 는 Java 17부터 정식적으로 확정된 것같습니다. JDK 17 Release Notes, Important C

tmd8633.tistory.com

 

 

 

SecurityConfig

        http.sessionManagement(session -> session
            .maximumSessions(1) // 사용자당 세션 최대 1개
            .maxSessionsPreventsLogin(true) // 새로운 로그인 차단
            .expiredUrl("/join?expired") // 세션 만료시 로그인으로 이동
        );

 

기존 세션을 만료시키기 전에 새로운 로그인을 차단하는 기능에 대해서 알아 보겠습니다. 이렇게 설정하고 동시 로그인을 하게되면

 

 

해당 url로 이동하게 됩니다. maxSessionPreventsLogin(true)는 

        http.formLogin(login -> login
                .loginPage("/join")
                .loginProcessingUrl("/join")
                .defaultSuccessUrl("/user")
                .failureUrl("/join?error")
            )

formLogin 에서 로그인 실패로 남게됩니다.

        http.formLogin(login -> login
                .loginPage("/join")
                .loginProcessingUrl("/join")
                .defaultSuccessUrl("/user")
                .failureHandler((request, response, exception) -> {
                    exception.printStackTrace();
                    response.sendRedirect("/join?error");
                })
            )

 

failureHandler에서 printStackTrace를 해보면

org.springframework.security.web.authentication.session.SessionAuthenticationException: Maximum sessions of 1 for this principal exceeded

이렇게 뜨는 것을 확인할 수 있습니다.  따라서 새로운 로그인을 차단하는 기능을 활성화 시킨다면 formLogin failureHandler에서 처리를 해야한다는 뜻입니다.

 

 

자 다시 돌아와서 maxSessionPreventsLogin(false) : 기존 세션 만료 로 돌려놓고 새로운 로그인을 시도해보겠습니다.

 

기존 로그인 했던 페이지에서 새로고침을 해보면 '/join?expired' 로 redirect 되었습니다. 세션 만료가 잘 되었군요. 이제 서버에는 세션은 최대 1개까지만 접속할 수 있게 되었습니다.

 

 

잠깐 SessionRegistry 는 왜 등록하지 않았나?

동시세션을 제어하기 위해서는 SessionRegistry를 등록해서 제어해야합니다. 그런데 위에 코드에는

.sessionRegistry(new SessionRegistryImpl())

이런 코드는 작성하지 않았습니다.

 

결론부터 말하자면 동시세션제어를 활성화할때 Bean에 SessionRegistry를 등록하지 않는다면 내부에서 new SessionRegistryImpl()을 생성해서 넣어줍니다.

 

 

.maximumSessions(1) 을 할때 내부 코드를 보면 ConcurrencyControlConfigurer 를 반환합니다.

    public SessionManagementConfigurer<H>.ConcurrencyControlConfigurer maximumSessions(int maximumSessions) {
        this.maximumSessions = maximumSessions;
        this.propertiesThatRequireImplicitAuthentication.add("maximumSessions = " + maximumSessions);
        return new ConcurrencyControlConfigurer();
    }

 

그 내부를 들어가보면 SessionManagementConfigurer 중첩클래스로 정의 되어있습니다.

 

그리고 getSessionRegistry 를 보면 sessionRegistry == null 이면 sessionRegistryImpl() 을 내부적으로 가지는것을 확인할 수 있습니다.

    // SessionManagementConfigurer 내부
    private SessionRegistry getSessionRegistry(H http) {
        if (this.sessionRegistry == null) {
            this.sessionRegistry = (SessionRegistry)this.getBeanOrNull(SessionRegistry.class);
        }

        if (this.sessionRegistry == null) {
            SessionRegistryImpl sessionRegistry = new SessionRegistryImpl();
            this.registerDelegateApplicationListener(http, sessionRegistry);
            this.sessionRegistry = sessionRegistry;
        }

        return this.sessionRegistry;
    }

 

사용처를 본다면

private ConcurrentSessionFilter createConcurrencyFilter(H http) {
    SessionInformationExpiredStrategy expireStrategy = this.getExpiredSessionStrategy();
    SessionRegistry sessionRegistry = this.getSessionRegistry(http);
    // ... 중략
    return concurrentSessionFilter;
}

private SessionAuthenticationStrategy getSessionAuthenticationStrategy(H http) {
        if (this.sessionAuthenticationStrategy != null) {
            return this.sessionAuthenticationStrategy;
        } else {
            // ... 중략
            if (this.isConcurrentSessionControlEnabled()) {
                SessionRegistry sessionRegistry = this.getSessionRegistry(http);
                // ... 중략
            } else {
                delegateStrategies.add(defaultSessionAuthenticationStrategy);
            }

            this.sessionAuthenticationStrategy = (SessionAuthenticationStrategy)this.postProcess(new CompositeSessionAuthenticationStrategy(delegateStrategies));
            return this.sessionAuthenticationStrategy;
        }
}

 

동시세션필터를 생성할때, SessionAuthenticationStrategy를 가져올때 사용되는것을 볼 수 있습니다.

 

 

2. 세션 생성 정책 설정

        http.sessionManagement(sessionManagement -> sessionManagement
            .sessionCreationPolicy(SessionCreationPolicy.IF_REQUIRED)     // 필요한 경우에만 생성
        );

이 값은 변경할 필요가 없을 것같습니다. 그렇기 때문에 생략하도록 하겠습니다.

다만, 이후에 JWT를 다룰때 SessionCreationPolicy.STATELESS(세션을 사용하지 않음)으로 변경할 예정입니다.

 

 

3. 세션 고정 보호 설정

        http.sessionManagement(session -> session
            .sessionFixation(SessionManagementConfigurer.SessionFixationConfigurer::changeSessionId)
            .maximumSessions(1) // 사용자당 세션 최대 1개
            .maxSessionsPreventsLogin(false) // 기존 세션 만료
            .expiredUrl("/join?expired") // 세션 만료시 로그인으로 이동
        );

세션 생성 정책과 마찬가지로 changeSessionId 가 기본값이기 때문에 생략하도록 하겠습니다.

 

 

 

4. 세션 타임아웃 설정

유효하지 않은 세션에 대한 설정인데요. 값이 변조되었거나 서버 재구동등 여러 요인으로 인해서 유효하지 않은 세션이 들어왔을때 처리를 담당합니다. 

 

        http.sessionManagement(session -> session
            .invalidSessionUrl("/join?invalid")
            .maximumSessions(1) // 사용자당 세션 최대 1개
            .maxSessionsPreventsLogin(false) // 기존 세션 만료
            .expiredUrl("/join?expired") // 세션 만료시 로그인으로 이동
        );

 

invalidSessionUrl을 "/join?invalid" 로 설정해줍니다.

 

.invalidSessionStrategy(new CustomInvalidSessionStrategy())

이렇게 추가적인 로직이 필요한경우 InvalidSessionStrategy 를 구현해서 넣을 수 있지만 쿠키제거, 세션무효화를 직접 작성해야하는 불편함이 있고 굳이 따로 객체를 만들어서 관리하는 수고로움 보다 JavaScript로 구현하는게 조금더 편한것 같더라구요.

// joinForm.html
<script>
    const urlParams = new URLSearchParams(window.location.search);

    // invalid 파라미터 체크
    if (urlParams.has('invalid')) {
        alert('세션이 유효하지 않습니다');
    }
    // expired 파라미터 체크
    else if (urlParams.has('expired')) {
        alert('세션이 만료되었습니다');
    }
</script>

무엇보다 사용자가 세션만료건 유효하지않건 알아봤자 중요하지도 않고 그게 무슨 뜻인지도 모를것이니까요. 물론 경우에 따라서는 Strategy를 구현해 사용하는 것도 방법입니다.

1. Session Management란?

Spring Security의 세션 관리는 사용자의 세션을 생성, 유지, 파괴하는 전반적인 프로세스를 관리합니다. 이는 보안과 사용자 경험 모두에 중요한 영향을 미칩니다. 또한 JWT이나 Session 관리를 하지않는 API를 사용하기전에 알아두어야 하기때문에 공부해보도록 하겠습니다.

 

 

 

2. Session Creation Policy (세션 생성 정책)

        http.sessionManagement(sessionManagement -> sessionManagement
            .sessionCreationPolicy(SessionCreationPolicy.IF_REQUIRED)     // 필요한 경우에만 생성(Default)
        );

 

정책 설명
SessionCreationPolicy.ALWAYS 항상 세션 생성
SessionCreationPolicy.IF_REQUIRED 필요한 경우에만 생성 (Default)
SessionCreationPolicy.NEVER 생성하지 않지만 존재하면 사용
SessionCreationPolicy.STATELESS 세션을 완전히 사용하지 않음 (JWT 등의 토큰 기반 인증에 적합)

 

 

3. Concurrent Session Control (동시 세션 제어)

@Bean
public HttpSessionEventPublisher httpSessionEventPublisher() {
    return new HttpSessionEventPublisher();
}
        http.sessionManagement(sessionManagement -> sessionManagement
            .maximumSessions(1)                     // 사용자당 최대 세션 수
            .maxSessionsPreventsLogin(true)         // true: 새로운 로그인 차단, false: 기존 세션 만료(기본값)
            .expiredUrl("/session-expired")         // 세션 만료시 이동할 URL
        );

 

3-1. HttpSessionEventPublisher

HttpSessionEventPublisher는 세션 생명주기 이벤트를 Spring의 ApplicationContext에 발행하는 역할을 합니다.

@Component
public class SessionEventListener {

    private final Logger logger = LoggerFactory.getLogger(getClass());

    @EventListener
    public void handleSessionCreated(HttpSessionCreatedEvent event) {
        HttpSession session = event.getSession();
        logger.info("새 세션 생성: {}", session.getId());
    }

    @EventListener
    public void handleSessionDestroyed(HttpSessionDestroyedEvent event) {
        HttpSession session = event.getSession();
        logger.info("세션 파괴됨: {}", session.getId());
    }

}

 

세션 모니터링

@Bean
public HttpSessionEventPublisher httpSessionEventPublisher() {
    return new HttpSessionEventPublisher();
}
@Bean
public SessionRegistry sessionRegistry() {
    return new SessionRegistryImpl();
}

        http.sessionManagement(session -> session
            .sessionRegistry(sessionRegistry())
        );
@Component
@Slf4j
@RequiredArgsConstructor
public class SessionEventListener {

    private final SessionRegistry sessionRegistry;

    @EventListener
    public void handleSessionDestroyed(HttpSessionDestroyedEvent event) {
        log.info("세션만료 : {}", event.getSession().getId());
        // 세션이 만료되면 해당 사용자의 세션 정보 정리
        SecurityContext securityContext = (SecurityContext) event.getSession()
            .getAttribute(HttpSessionSecurityContextRepository.SPRING_SECURITY_CONTEXT_KEY);
        if (securityContext != null) {
            sessionRegistry.removeSessionInformation(event.getSession().getId());
        }
    }

    @EventListener
    public void handleSessionCreated(HttpSessionCreatedEvent event) {
        log.info("세션생성 : {}", event.getSession().getId());
        HttpSession session = event.getSession();
        // 세션 생성 시 기본 타임아웃 설정
        session.setMaxInactiveInterval(60 * 30); // 30분
    }

}

 

3-2. 이전 세션 만료 전략

.maximumSessions(1)
.maxSessionsPreventsLogin(false)  // 기본값

 

3-3. 새 로그인 차단 전략

.maximumSessions(1)
.maxSessionsPreventsLogin(true)

 

 

4. Session Fixation Protection (세션 고정 보호)

 

4-1. 세션 고정 보호가 뭘까?

정확히는 세션 고정 공격 보호 (Session Fixation Attack Protection) 입니다. 세션 고정 공격은 악의적인 공격자가 사이트에 접속해서 세션을 생성한 다음 동일한 세션으로 다른 사용자가 접속하도록 유도할 수 있는 잠재적인 공격입니다. (예, 세션 식별자를 매개변수로 포함하는 링크를 전송) Spring Security는 사용자가 로그인할 때 새 세션을 생성하거나 세션 ID를 변경하여 이를 자동으로 보호하는데 이를 설정하는 것이 sessionFixation 설정입니다.

출처 : https://blog.pollra.com/odop-day-76

 

4-2. 세션 고정 보호 설정

        http.sessionManagement(sessionManagement -> sessionManagement
            .sessionFixation(fixation -> fixation
                .changeSessionId() // 세션 ID만 변경 (Servlet 3.1+ 컨테이너 기본값)
            )
        );

 

 

메소드 설명
newSession() 완전히 새로운 세션 생성
migrateSession() 이전 세션 속성을 새 세션으로 복사 (Servlet 3.0이하 Default)
changeSessionId() 세션 ID만 변경 (Servlet 3.1 이상 Default)
none() 보호 비활성화

 

 

 

5. Session Timeout (세션 타임아웃)

세션은 자체적으로 만료되며 Security Context가 제거되도록 해야할 것은 없습니다. 그러므로 세션이 만료된 시점을 감지하고 사용자가 특정 작업을 수행할 수 있도록 엔드포인트를 리디렉션할 수 있게만 해주면됩니다. 이는 invalidSessionUrl 에서 이루어집니다.

 

        http.sessionManagement(session -> session
            .invalidSessionUrl("/invalidSession") // 만료된 세선이 접근할 경우 "/invalidSession" 으로 이동
        );

 

 

        http.sessionManagement(session -> session
            .invalidSessionStrategy(new CustomInvalidSessionStrategy())
        );
        
public class CustomInvalidSessionStrategy implements InvalidSessionStrategy {

    @Override
    public void onInvalidSessionDetected(HttpServletRequest request, HttpServletResponse response) throws IOException {
        // Session 무효화
        HttpSession session = request.getSession(false);
        if (session != null) {
            session.invalidate();
        }

        // JSESSIONID 쿠키 삭제
        Cookie[] cookies = request.getCookies();
        if (cookies != null) {
            for (Cookie cookie : cookies) {
                if ("JSESSIONID".equals(cookie.getName())) {
                    cookie.setValue("");
                    cookie.setPath("/");
                    cookie.setMaxAge(0);
                    response.addCookie(cookie);
                    break;
                }
            }
        }
        response.setContentType("text/html;charset=UTF-8");
        PrintWriter out = response.getWriter();
        out.println("<script>alert('세션이 만료되었습니다.');</script>");
        out.flush();
    }

}

 

invalidSessionUrl과 invalidSessionStrategy는 동시에 사용할 수 없습니다. 둘다 정의 되어있다면 invalidSessionStrategy만 적용됩니다. 그냥 세션 만료시 이동을 원할경우 invalidSessionUrl을 사용하고, 복잡한 로직이 추가되어야 한다면 invalidSessionStrategy를 사용하시면 됩니다.

 

 

 

주의사항

로그아웃 시에 쿠키가 제대로 삭제되지 않아 문제가 발생할 수 있습니다.

        http.logout(logout -> logout
            // 모든 쿠키 제거
            .addLogoutHandler(new HeaderWriterLogoutHandler(new ClearSiteDataHeaderWriter(ClearSiteDataHeaderWriter.Directive.COOKIES)))
            // 특정 쿠키 제거
            .deleteCookies("JSESSIONID", "remember-me")
        );

이렇게 명시적으로 쿠키를 제거해줍시다.

ClearSiteData를 지원하지 않는 브라우저에서는 정상적으로 동작하지 않을 수 있습니다.

 

 

 

 

6. SessionManagementFilter

sessionManagement는 SessionManagementFilter에서 사용됩니다. 그 역할로는 

 

  • 세션 생성 전략 관리
  • 동시 세션 제어
  • 세션 고정 보호
  • 유효하지 않은 세션 처리

우리가 위에서 알아본 것과 같습니다.

 

6-1. SessionManagementFilter 구조

  • SecurityContextRepository의 내용을 현재 SecurityContextHolder의 내용과 비교 검사
  • 이를 통해 현재 요청 중에 사용자가 인증되었는지 확인
  • 주로 pre-authentication이나 remember-me와 같은 비대화형 인증 메커니즘에서 사용됨

처리 흐름

  • SecurityContextRepository에 Security Context가 있다면 아무 작업도 하지 않음
  • SecurityContextRepository에 context가 없고, SecurityContext가 Authentication 객체를 포함하고 있다면 (익명객체가 아닌)
    • 이전 필터에서 이미 인증되었다고 가정
    • 설정된 SessionAuthenticationStrategy를 실행

미인증 사용자 처리

  • 유효하지 않은 세션 ID가 요청되었는지 확인 (예: 타임아웃으로 인한 유효하지 않은 세션)
  • 설정된 InvalidSessionStrategy가 있다면 이를 실행

 

6-2. SecurityContextRepository

SecurityContextRepository는 SecurityContext를 저장하고 불러오는 역할을 하는 인터페이스입니다. 주로 HTTP 세션에 SecurityContext를 저장하고 검색하는 작업을 담당합니다. 기본객체는 HttpSessionSecurityContextRepository 입니다.

기본 구현

@Configuration
@EnableWebSecurity
public class SecurityConfig {
    
    @Bean
    public SecurityFilterChain filterChain(HttpSecurity http) throws Exception {
        http.securityContext((securityContext) -> securityContext
            .securityContextRepository(new HttpSessionSecurityContextRepository())
        );
        return http.build();
    }
}​


DelegatingSecurityContextRepository 사용 시

@Bean
public SecurityContextRepository securityContextRepository() {
    return new DelegatingSecurityContextRepository(
        new HttpSessionSecurityContextRepository(),
        new RequestAttributeSecurityContextRepository()
    );
}​

 

 

 

7. 보안설정

server.servlet.session.cookie.secure=true
server.servlet.session.cookie.http-only=true

 

server.servlet.session.cookie.secure=true

  • HTTPS를 통해서만 쿠키가 전송되도록 설정
  • HTTP로 요청시 쿠키가 전송되지 않음
  • 중간자 공격(Man-in-the-Middle Attack) 방지
  • 쿠키 탈취 위험 감소

server.servlet.session.cookie.http-only=true

  • JavaScript를 통한 쿠키 접근을 차단
  • XSS(Cross-Site Scripting) 공격 방지
  • 클라이언트 측 스크립트에서 document.cookie로 접근 불가

 

7-1. 추가 설정

# 쿠키 만료 시간 설정
server.servlet.session.timeout=30m

# 쿠키 경로 설정
server.servlet.session.cookie.path=/

# 쿠키 도메인 설정
server.servlet.session.cookie.domain=example.com

# 쿠키 이름 변경
server.servlet.session.cookie.name=MYSESSIONID

# SameSite 설정
server.servlet.session.cookie.same-site=strict

 

 

 

글이 너무 길어져서 여기까지 작성하겠습니다.

SessionManagement 관련된 내용은 어마어마하게 많습니다. 다음글에서는 이 글을 토대로 어떻게 적용하는지에 대해서 알아보도록하겠습니다.

이전에 Flutter로 도넛차트를 구현했었습니다. 이번에는 디자인툴인 피그마로 도넛차트 만드는법에 대해서 알아보겠습니다.

 

 

1. 원 도형 생성

Ellipse (O) 를 80x80 사이즈로 생성합니다.

 

2. Arc 설정

도형 오른쪽에 마우스를 올리면 Arc를 설정할 수 있습니다. 잡고 12시방향에 위치시켜줍니다.

 

2. Ratio 설정

 

이 상태가 되었으면 Ratio를 잡고 원 밖방향으로 끌어주어 80%를 맞춰줍니다.

 

 

3. Background

도형을 복사 붙혀넣기 해서 겹치게 만들고 뒤에 도형에 Sweep를 잡고 100%를 만들어 원을 만들어줍니다.

4. 차트 만들기

차트를 표시할 도형을 선택하고 색상을 선택합니다. radius를 주고싶다면 주면 됩니다.

 

 

5. 시작점 변경

 

 

시작점은 변경해줍니다 Start부분을 잡고 12시 방향으로 바꿔줍니다.

 

끝!

1. CORS란 무엇인가?

CORS(Cross-Origin Resource Sharing)는 웹 브라우저에서 외부 도메인 리소스를 안전하게 요청할 수 있도록 하는 표준 규약입니다. 프론트엔드와 백엔드가 분리하는데 있어 CORS에 대해서 반드시 짚고 넘어가야합니다. 그래서 온르은 CORS에 대해서 공부해보겠습니다.

 

 

 

2. CORS의 필요성

핵심은 외부로부터 리소스를 공유하는 것입니다. 요즘 웹 애플리케이션에 개발에서 백엔드와 프론트엔드를 구분하지 않고 개발하는 곳은 거의 없을 겁니다.

 

  • 프론트엔드와 백엔드의 분리
  • 마이크로서비스 아키텍처 도입
  • 외부 API 활용
  • SPA(Single Page Application) 개발 방식

이러한 상황에서 다른 출처(Origin)의 리소스를 안전하게 요청하고 사용할 수 있어야 했고, 이를 위한 표준이 바로 CORS입니다.

 

 

 

3. Same-Origin Policy

Same-Origin Policy는 웹 브라우저의 기본적인 보안 정책으로, 같은 출처에서만 리소스를 공유할 수 있도록 제한합니다.

출처(Origin)는 다음 세 가지 요소로 결정됩니다:

  • 프로토콜 (http, https)
  • 호스트 (domain)
  • 포트 번호
  • http://example.com/path1, https://example.com/path2 는 프로토콜이 다르므로 다른 출처로 간주됩니다.

 

 

4. CORS 동작 방식

CORS는 HTTP 헤더를 통해 동작합니다. 주요 헤더는 다음과 같습니다:

 

4-1. 요청 헤더

  • Origin: 요청을 보내는 출처
  • Access-Control-Request-Method: 실제 요청에서 사용할 HTTP 메서드
  • Access-Control-Request-Headers: 실제 요청에서 사용할 헤더

4-2. 응답 헤더

  • Access-Control-Allow-Origin: 허용된 출처
  • Access-Control-Allow-Methods: 허용된 HTTP 메서드
  • Access-Control-Allow-Headers: 허용된 헤더
  • Access-Control-Max-Age: 프리플라이트 요청 캐시 시간
  • Access-Control-Allow-Credentials: 인증 정보 포함 여부

 

5. CORS 요청의 종류

 

5-1. Simple Request

  • GET, HEAD, POST 중 하나의 메서드 사용
  • 허용된 헤더만 사용
  • Content-Type이 다음 중 하나:
    • application/x-www-form-urlencoded
    • multipart/form-data
    • text/plain

 

5-2. Preflight Request

Simple Request 조건을 만족하지 않는 요청의 경우, 브라우저는 실제 요청 전에 OPTIONS 메서드를 사용한 예비 요청을 보냅니다.

 

5-3. Credentialed Request

인증 정보(쿠키, HTTP 인증)를 포함한 요청입니다.

 

 

 

6. Spring Security CORS 설정

Spring Security CORS 설정에 대해서 알아보겠습니다.

 

 

UrlBasedCorsConfigurationSource apiConfigurationSource() {
        CorsConfiguration configuration = new CorsConfiguration();

        // 허용할 출처(Origin) 설정
        // https://api.example.com 에서 오는 요청만 허용
        configuration.setAllowedOrigins(List.of("https://api.example.com"));

        configuration.setAllowedOriginPatterns(List.of(
            "https://*.example.com",     // example.com의 모든 서브도메인 허용
            "https://*.example.*.com",   // 더 복잡한 패턴 매칭도 가능
            "http://localhost:[*]"       // 로컬호스트의 모든 포트 허용
        ));

        // 허용할 HTTP 메서드 설정
        // GET과 POST 메서드만 허용 (PUT, DELETE, PATCH 등은 차단됨)
        configuration.setAllowedMethods(List.of("GET","POST"));

        // 허용할 헤더 설정
        // 모두 허용
        configuration.setAllowedHeaders(List.of("*"));

        // 클라이언트에게 노출할 헤더
        configuration.setExposedHeaders(List.of("Authorization"));

        // allowCredentials를 true로 설정할 경우, allowedOrigins에 "*"를 사용할 수 없습니다
        configuration.setAllowCredentials(true);

        // CORS 프리플라이트 요청의 캐시 시간
        configuration.setMaxAge(3600L);

        // URL 패턴별로 CORS 설정을 적용할 수 있는 객체 생성
        UrlBasedCorsConfigurationSource source = new UrlBasedCorsConfigurationSource();

        // 모든 경로("/**")에 대해 위에서 설정한 CORS 설정을 적용
        source.registerCorsConfiguration("/**", configuration);


        return source;
    }

 

    @Bean
    SecurityFilterChain defaultSecurityFilterChain(HttpSecurity http) throws Exception {

        http.cors(cors -> cors
            .configurationSource(apiConfigurationSource())
        );
        
        return http.build();
    }

 

6-1. Preflight 란?

configuration.setMaxAge() 에 대해서 간단하게 알아보겠습니다.

  • 브라우저는 실제 요청 전에 OPTIONS 메서드를 사용하여 preflight 요청을 보냅니다
  • 이 요청으로 해당 출처가 안전한지, 어떤 메서드와 헤더가 허용되는지 확인합니다
  • 매 요청마다 preflight를 보내면 성능 저하가 발생할 수 있습니다

6-1-1. maxAge의 역할

// 예시: preflight 응답 헤더
Access-Control-Max-Age: 3600
Access-Control-Allow-Methods: GET, POST
Access-Control-Allow-Headers: Content-Type

 

  • 브라우저가 preflight 응답을 캐시하는 시간을 지정
  • 캐시 기간 동안은 동일한 요청에 대해 preflight를 다시 보내지 않음
  • 서버 부하 감소와 성능 향상에 도움

6-1-2. 브라우저별 최대 제한 시간

// 브라우저별 최대 캐시 시간이 다름
Chrome: 2시간 (7200초)
Firefox: 24시간 (86400초)
Safari: 7일

 

 

6-1-3. 장점

  • 서버 부하 감소
  • 네트워크 트래픽 감소
  • 응답 시간 개선

6-1-4. 단점

  • CORS 정책 변경 시 캐시된 정책이 즉시 적용되지 않을 수 있음
  • 브라우저마다 다른 최대 제한으로 일관성 있는 동작을 보장하기 어려움

 

 

6-2. allowCredentials와 allowedOrigins 설정

allowCredentials를 true로 설정하고 allowedOrigins에 "*"를 함께 사용할 수 없는 것은 중요한 보안상의 이유 때문입니다.

 

credentials에는 쿠키, HTTP 인증 토큰과 같은 민감한 인증 정보가 포함됩니다. 그런데 allowedOrigins에 모든 도메인("*")의 접근을 허용한다면 악의적인 웹사이트에서 사용자의 인증정보를 이용해 요청을 보낼 수 있게 됩니다.

 

 

[CS][Spring Security] CSRF란?

CSRF란?CSRF Cross-Site Request Forgery의 약자로 인증된 사용자의 권한을 악용하여 해당 사용자가 의도하지 않은 요청을 웹사이트에 전송하는 공격 기법입니다. 공격자는 사용자가 이미 인증된 상태를

tmd8633.tistory.com

이에 대해서는 CSRF에 대해서 읽어보시기 바랍니다.

 

따라서 브라우저는 이러한 보안 위험을 방지하기 위해 allowCredentials(true)와 allowedOrigins("*")의 조합을 명시적으로 금지하고 있습니다. 이는 웹 보안의 기본 원칙인 "최소 권한의 원칙"을 따르는 것이며, 실수로 인한 보안 취약점 발생을 방지합니다.

 

 

 

7. 자주 발생하는 CORS 에러와 해결 방법

7-1. No 'Access-Control-Allow-Origin' header is present

  • 원인: 서버에서 Access-Control-Allow-Origin 헤더를 설정하지 않음
  • 해결: 서버에서 적절한 CORS 설정 추가

 

7-2. Method not allowed

  • 원인: 허용되지 않은 HTTP 메서드 사용
  • 해결: allowedMethods에 필요한 메서드 추가

 

7-3. Credentials flag is true, but Access-Control-Allow-Credentials is false

  • 원인: 인증 정보를 포함한 요청에 대한 서버 설정 미비
  • 해결: allowCredentials(true) 설정 추가

 

8. 보안 관련 고려사항

8-1. Origin 설정

  • "*" 대신 구체적인 도메인 지정
  • 신뢰할 수 있는 출처만 허용

8-2. 인증 관련

  • allowCredentials(true) 사용 시 구체적인 출처 지정 필요
  • 보안에 민감한 API의 경우 더 엄격한 CORS 정책 적용

8-3. 헤더 설정

  • 필요한 헤더만 허용
  • exposedHeaders 설정 시 최소한의 헤더만 노출

8-4. 캐시 설정

  • maxAge 값을 적절히 설정하여 불필요한 프리플라이트 요청 감소

 

9. 결론

CORS는 현대 웹 개발에서 필수적인 보안 메커니즘입니다. 올바른 CORS 설정은 웹 애플리케이션의 보안과 기능성을 모두 만족시킬 수 있습니다. 각 프로젝트의 요구사항과 보안 정책에 맞게 적절한 CORS 설정을 적용하시기 바랍니다.

'일반 > CS' 카테고리의 다른 글

[CS][Spring Security] CSRF란?  (0) 2024.12.12
Maven Central Repository에 라이브러리 등록하기  (0) 2024.09.19
[CS] MVC 패턴  (0) 2024.04.17
HTTP GET과 POST 차이  (0) 2024.01.25
URI와 URL의 차이점 (Feat : URN)  (0) 2024.01.21

Anonymous 인증이란?

Spring Security의 Anonymous 인증은 인증되지 않은 사용자(로그인하지 않은 사용자)를 처리하는 메커니즘입니다. 인증되지 않은 요청에 대해 AnonymousAuthenticationToken을 생성하여 보안 컨텍스트에 저장합니다. 내용을 길지않으니 빠르게 알아보겠습니다.

 

 

 

사용법

anonymous 설정은 아래와 같습니다.

        http.anonymous(anonymous -> anonymous
            .principal("anonymousUser")      // 익명 사용자의 주체
            .authorities("ROLE_ANONYMOUS")    // 익명 사용자의 권한
        );
        // 비활성화
        http.anonymous(AbstractHttpConfigurer::disable);

 

 

 

사용처

로그인/회원가입은 로그인한 회원은 접근하지 못해야합니다.

        http.authorizeHttpRequests(request -> request
            .requestMatchers("/user/**").hasAnyRole("USER", "ADMIN")
            .requestMatchers("/admin/**").hasRole("ADMIN")
            .requestMatchers("/join", "/signup").anonymous()
            .anyRequest().permitAll()
        );

/join 과 /signup URL에는 로그인하지 않은 사용자만 접근하도록 설정했습니다.

 

 

결과

이제 로그인하고 /join 에 접속해보면

403 Forbidden 이 발생합니다.

 

 

Anonymous 인증의 동작 방식

 

Anonymous 인증 필터

  • AnonymousAuthenticationFilter가 인증되지 않은 요청을 처리
  • SecurityContextHolder에 인증 객체가 없을 때 AnonymousAuthenticationToken 생성
  • 필터 체인의 맨 마지막 부분에서 동작

 

인증 객체 구조

AnonymousAuthenticationToken {
    principal: "anonymousUser",
    authorities: ["ROLE_ANONYMOUS"],
    details: WebAuthenticationDetails,
    authenticated: true
}

 

 

 

주의사항

Anonymous 를 사용할 때에는 알고있어야 하는 것이 있습니다. AnonymousAuthenticationToken 이 SecurityContextHolder에 생성되었다는 것은 인증된 상태로 간주된다는 것입니다.

 

 

 

[Spring Security] 스프링 시큐리티 이해하기 (1)

Spring SecuritySpring Security는 인증, 인가 및 일반적인 공격에 대한 보호를 제공하는 프레임워크 입니다. 서비스를 개발할때 필수적으로 구현해야하는것이 로그인, 회원가입과 권한인데요. 이를 Sprin

tmd8633.tistory.com

이 특징에 대해서 3-1 SecurityContextHolder 부분을 읽어보시면 이해가 되실겁니다.

 

 

Authentication auth = SecurityContextHolder.getContext().getAuthentication();
System.out.println("isAuthenticated: " + auth.isAuthenticated());  // true 반환

이렇게 true가 반환되니 주의하시기 바랍니다.

 

실제 로그인한 사용자인지 파악하기위해서는

if (auth instanceof AnonymousAuthenticationToken) {
    // 익명 사용자
} else {
    // 실제 인증된 사용자
}

이렇게 사용하셔야합니다.

 

 

 

 

ExceptionHandling

http.exceptionHandling(handling -> handling
    .accessDeniedHandler()           // 권한 부족 시 처리
    .accessDeniedPage()             // 권한 부족 시 리다이렉트할 페이지
    .authenticationEntryPoint()     // 인증되지 않은 사용자 처리
    .defaultAuthenticationEntryPointFor()  // 특정 요청에 대한 인증 진입점 설정
);

 

 

accessDeniedHandler

.accessDeniedHandler((request, response, accessDeniedException) -> {
    // 인증된 사용자가 권한이 부족한 리소스에 접근할 때
    response.setContentType("text/html;charset=UTF-8");
    PrintWriter out = response.getWriter();
    out.println("<script>alert('권한이 없습니다.'); window.location.href='/';</script>");
    out.flush();
})

 

 

accessDeniedPage

.accessDeniedPage("/error/403")  // 간단히 특정 페이지로 리다이렉트

 

 

authenticationEntryPoint

.authenticationEntryPoint((request, response, authException) -> {
    // 인증되지 않은 사용자가 보호된 리소스에 접근할 때
    response.sendRedirect("/login");
})

 

 

defaultAuthenticationEntryPointFor

.defaultAuthenticationEntryPointFor(
    new LoginUrlAuthenticationEntryPoint("/api/login"),
    new AntPathRequestMatcher("/api/**")
)

 

 

전체 코드

        http.exceptionHandling(handling -> handling
            // 1. 권한 부족 처리 (403)
            .accessDeniedHandler((request, response, accessDeniedException) -> {
                response.setContentType("text/html;charset=UTF-8");
                PrintWriter out = response.getWriter();
                out.println("<script>alert('권한이 없습니다.'); history.back();</script>");
                out.flush();
            })
            
            // 2. 인증되지 않은 사용자 처리 (401)
            .authenticationEntryPoint((request, response, authException) -> {
                if (isAjaxRequest(request)) {
                    response.sendError(HttpServletResponse.SC_UNAUTHORIZED);
                } else {
                    response.sendRedirect("/login");
                }
            })
            
            // 3. API 요청에 대한 특별한 처리
            .defaultAuthenticationEntryPointFor(
                new HttpStatusEntryPoint(HttpStatus.UNAUTHORIZED),
                new AntPathRequestMatcher("/api/**")
            )
        );
        
    // AJAX 요청 확인
    private boolean isAjaxRequest(HttpServletRequest request) {
        return "XMLHttpRequest".equals(request.getHeader("X-Requested-With"));
    }

 

코드만 봐서는 헷갈리는게 많습니다. 정리해보자면 다음과 같습니다.

 

주요 차이점

  1. accessDeniedHandler vs authenticationEntryPoint
    • accessDeniedHandler: 인증은 됐지만 권한이 없을 때 (403)
    • authenticationEntryPoint: 인증이 안된 경우 (401)
  2. accessDeniedPage vs accessDeniedHandler
    • accessDeniedPage: 단순 페이지 리다이렉트
    • accessDeniedHandler: 더 복잡한 로직 구현 가능
  3. defaultAuthenticationEntryPointFor
    • 특정 URL 패턴에 대해서만 다른 인증 진입점 설정 가능
    • API와 웹 페이지를 다르게 처리할 때 유용

 

 

오늘은 여기까지하고 마치겠습니다.

Remember-Me란?

Remember-Me는 사용자가 브라우저를 닫았다가 다시 열어도 로그인 상태를 유지하는 기능입니다. 쿠키를 사용하여 구현되며, Spring Security는 두 가지 구현 방식을 제공합니다. 

 

  • Simple Hash-Based Token
  • Persistent Token (Database)

두가지 다 알아보도록 합시다.

 

 

 

RememberMe 처리과정

구현하기 전에 어떤 과정을 통해 쿠키로 만들어지는지 짚고 넘어가겠습니다.

  1. 로그인 시 rememberMe 체크박스를 선택하고 로그인
  2. 토큰생성 : base64(username + ":" + expirationTime + ":" + md5Hex(username + ":" + expirationTime + ":" + password + ":" + key))
  3. 토큰 저장
    • Simple Hash-Based Token : 쿠키 생성
    • Persistent Token : 쿠키생성, DB 저장ㅋ
  4. 사용자 접근 시 토큰 검증
    • Simple Hash-Based Token : 쿠키값이 유효한 경우 자동로그인
    • Persistent Token : DB 에 저장된 값과 비교하고 유효한 경우 자동로그인

 

 

Simple Hash-Based Token

    <form th:action="@{/join}" th:method="post">
        <input type="text" name="username">
        <input type="password" name="password">
        <label>
            <input type="checkbox" name="remember-me" /> Remember Me
        </label>
        <input type="submit" value="로그인">
    </form>

먼저 jonForm.html에 remember-me 체크박스를 하나 추가해줍시다.

 

그다음 RememberMeConfig 를 하나 만듭니다.

@Configuration
@RequiredArgsConstructor
public class RememberMeConfig {

    private static final String KEY = "MY_KEY";

    @Bean
    RememberMeServices rememberMeServices(UserDetailsService userDetailsService) {
        TokenBasedRememberMeServices rememberMe = new TokenBasedRememberMeServices(KEY, userDetailsService);
        rememberMe.setAlwaysRemember(false);
        rememberMe.setTokenValiditySeconds(60 * 60 * 24);
        return rememberMe;
    }
    
}

KEY는 비밀키 역할을 합니다. TokenBasedRememberMeServices에는 인코딩 알고리즘과 매칭 알고리즘이 존재합니다.

인코딩 알고리즘 기본값은 SHA-256, 매칭 알고리즘은 MD5입니다. 알고리즘은 설정을 통해 수정할 수 있습니다.

 

UserDetailsService는 저번 글에서 Bean으로 등록해놓았습니다.

 

    @Bean
    RememberMeServices rememberMeServices(UserDetailsService userDetailsService) {
        RememberMeTokenAlgorithm sha256 = RememberMeTokenAlgorithm.SHA256;
        TokenBasedRememberMeServices rememberMe = new TokenBasedRememberMeServices(KEY, userDetailsService, sha256);
        rememberMe.setMatchingAlgorithm(RememberMeTokenAlgorithm.MD5);
        rememberMe.setAlwaysRemember(false);
        rememberMe.setTokenValiditySeconds(60 * 60 * 24);
        return rememberMe;
    }

 

 

마지막으로 SecurityConfig로 돌아와

        http.rememberMe(remember -> remember
            .rememberMeServices(rememberMeServices)
        );

이렇게 설정해줍시다.

 

설정이 끝났습니다. 이제 로그인할때 rememberMe를 체크하고 로그인하면 쿠키가 살아있는동안 자동로그인이 되는것을 확인할 수 있습니다.

rememberMe 체크 후 로그인 -> 브라우저 종료 -> /user 로 바로 접속

 

 

 

 

Persistent Token

다음은 DB에 쿠키를 저장하는 방식으로 구현해보겠습니다.

 

build.gradle

	implementation 'org.springframework.boot:spring-boot-starter-data-jpa'
	runtimeOnly 'com.h2database:h2'

 

application.properties

# DataSource 설정
spring.datasource.url=jdbc:h2:mem:testdb
spring.datasource.driverClassName=org.h2.Driver
spring.datasource.username=sa
spring.datasource.password=

# JPA 설정
spring.jpa.database-platform=org.hibernate.dialect.H2Dialect
spring.jpa.hibernate.ddl-auto=update
spring.jpa.show-sql=true

 

@Entity
public class PersistentLogins {

    @Id
    private String series;
    private String username;
    private String token;
    private LocalDateTime lastUsed;
}

 

이렇게 추가해주도록 합시다.

PersistentLogins 객체는 공식문서에 적힌대로 작성했습니다. 실제로도 이렇게 해야합니다.

username: 사용자 이름
series: 쿠키의 series 값과 매칭됨
token: 쿠키의 token 값과 매칭됨
last_used: 마지막 사용 시간
 

Remember-Me Authentication :: Spring Security

Remember-me or persistent-login authentication refers to web sites being able to remember the identity of a principal between sessions. This is typically accomplished by sending a cookie to the browser, with the cookie being detected during future sessions

docs.spring.io

 

 

    @Bean
    RememberMeServices rememberMeServices(UserDetailsService userDetailsService) {
        PersistentTokenBasedRememberMeServices service = new PersistentTokenBasedRememberMeServices(KEY, userDetailsService, new CustomInMemoryTokenRepository());
        service.setAlwaysRemember(false);
        service.setTokenValiditySeconds(2592000);
        return service;
    }

 

RememberMeServices에 구현체를 TokenBasedRememberMeServices 에서 PersistentTokenBasedRememberMeServices로 변경해줍니다. PersistentTokenBasedRememberMeServices에는 PersistentTokenRepository가 들어가야하는데 그 구현체는

InMemoryTokenRepositoryImpl과 JdbcTokenRepositoryImpl이 있습니다.

 

저는 테스트하기 위해 InMemoryRepositoryImpl 을 구현할거고, 로그도 확인하기 위해 CustomInMemoryRepository 를 따로 구현했습니다. 그냥 InMemoryRepositoryImpl 넣으셔도 무방합니다.

 

public class CustomInMemoryTokenRepository extends InMemoryTokenRepositoryImpl {
    private final Logger logger = LoggerFactory.getLogger(this.getClass());

    @Override
    public void createNewToken(PersistentRememberMeToken token) {
        super.createNewToken(token);
        logger.info("New token created: {}", token.getUsername());
    }

    @Override
    public void updateToken(String series, String tokenValue, Date lastUsed) {
        super.updateToken(series, tokenValue, lastUsed);
        logger.info("Token updated - series: {}, lastUsed: {}", series, lastUsed);
    }
}

 

 

이제 로그인해보면

정상적으로 토큰이 생성되었습니다. 이제 브라우저를 닫고 다시 접속해보면

 

잘 되는걸 볼 수 있습니다.

 

 

 

비교

따로 장단점을 적지않더라도 Persistent Token이 더 안전하다고 느끼실겁니다.

 

Persistent Token의 장점

  1. 토큰이 데이터베이스에 저장되므로 더 안전
  2. 토큰 탈취 시도를 감지 가능
  3. 사용자별 로그인 세션 관리 가능
  4. 특정 사용자의 remember-me 토큰만 선택적으로 만료 가능

Persistent Token 주의사항:

  1. 반드시 HTTPS 사용 권장
  2. 토큰 유효기간을 적절히 설정
  3. 중요한 작업 시에는 재인증 요구
  4. 정기적으로 만료된 토큰 정리 필요

Persistent Token을 사용할때

service.setUseSecureCookie(true);

로 설정하는게 좋습니다.

 

 

  • true로 설정 시
    • HTTPS 연결에서만 쿠키 전송
    • HTTP 연결에서는 쿠키가 전송되지 않음
    • 중간자 공격(Man-in-the-Middle) 방지
    • 쿠키 탈취 위험 감소
  • false로 설정 시
    • HTTP, HTTPS 모두에서 쿠키 전송
    • 개발 환경에서 주로 사용
    • 보안상 취약할 수 있음

개발환경에서는 false, 서비스환경에서는 true로 설정해줘야합니다.

 

개요

Spring Security 에서 ThreadLocal에대해서 언급한적이 있어 글로 남겨봅니다.

 

ThreadLocal이란?

ThreadLocal은 Java에서 제공하는 클래스로, java.lang 패키지에 존재합니다.

각 스레드마다 독립적인 변수를 가질 수 있게 해주는 기능인데 쉽게 말해, ThreadLocal에 저장된 데이터는 해당 Thread만 접근할 수 있는 데이터 저장소라고 할 수 있습니다. 개인이 가지는 사물함이라고 생각하시면 쉽습니다.

 

 

 

동시성 문제

그럼 왜 ThreadLocal을 알아야 할까요? JAVA는 멀티쓰레딩 환경으로 한 메소드에 동시에 접근이 가능합니다. 그래서 항상 동시성문제에 신경써야하죠. 특히 Spring Container에서는 Bean으로 등록해 객체를 싱글톤으로 관리해 자원을 최소화 합니다. 하나의 객체를 여러명이 사용하면 문제가 생기기 마련입니다. 읽기 메소드는 멱등성을 보장받아 문제가 생기지않지만 수정, 생성, 삭제 등 데이터가 변경되었을 때 문제가 생기죠.

 

싱글톤패턴에 대해서 알고싶다면 아래 글을 읽어보세요!

 

 

자바(JAVA) - 싱글톤 패턴(Singleton Pattern)

싱글톤 패턴 싱글톤 패턴은 단 하나의 유일한 객체를 만들기 위한 코드 패턴입니다. 싱글톤 패턴을 따르는 클래스는 생성자가 여러번 호출되더라도 실제 생성되는 객체는 단 하나이고, 최초 생

tmd8633.tistory.com

 

 

자바(JAVA) - 싱글톤 패턴(Singleton Pattern)

싱글톤 패턴 싱글톤 패턴은 단 하나의 유일한 객체를 만들기 위한 코드 패턴입니다. 싱글톤 패턴을 따르는 클래스는 생성자가 여러번 호출되더라도 실제 생성되는 객체는 단 하나이고, 최초 생

tmd8633.tistory.com

 

public class BankAccount {
    private int balance = 0;
    
    // 동시성 문제가 발생하는 메서드
    public int transfer(int amount) {
        int currentBalance = balance;         // 현재 잔액 읽기
        balance = currentBalance + amount;    // 잔액 업데이트
        return balance;
    }
    
    public int getBalance() {
        return balance;
    }
}

 

가령 BankAccount 객체가 싱글톤으로 관리되고, 여러 사용자가 BankAccount를 사용한다고 했을때,

 

  1. 사용자A : bankAccount.transfer(1000)
  2. 사용자A 현재 잔액 읽음 : 현재 잔액 0
  3. 사용자B : bankAccount.transfer(1000)
  4. 사용자B 현재 잔액 읽음 : 현재 잔액 0
  5. 사용자A : 잔액 업데이트 - 결과 반환 1000
  6. 사용자B : 잔액 업데이트 - 결과 반환 1000

최종 잔액이 2000이 될 것으로 예상했지만 동시성 문제로 최종 잔액이 1000이 되었습니다.

 

 

 

ThreadLocal의 특징

 

  • 스레드 안전성: 각 스레드가 자신만의 독립된 변수를 가지므로, 동기화 없이도 스레드 안전성을 보장합니다.
  • 데이터 격리: 다른 스레드의 데이터에 접근할 수 없어 데이터 격리가 완벽하게 이루어집니다.
  • 성능: 동기화가 필요 없으므로, synchronized 키워드 사용 대비 성능상 이점이 있습니다.

 

 

ThreadLocal

public class ThreadLocal<T> {
    
    public T get() {
        Thread t = Thread.currentThread();
        ThreadLocalMap map = getMap(t);
        if (map != null) {
            ThreadLocalMap.Entry e = map.getEntry(this);
            if (e != null) {
                @SuppressWarnings("unchecked")
                T result = (T)e.value;
                return result;
            }
        }
        return setInitialValue();
    }
    
    boolean isPresent() {
        Thread t = Thread.currentThread();
        ThreadLocalMap map = getMap(t);
        return map != null && map.getEntry(this) != null;
    }
    
    public void set(T value) {
        Thread t = Thread.currentThread();
        ThreadLocalMap map = getMap(t);
        if (map != null) {
            map.set(this, value);
        } else {
            createMap(t, value);
        }
    }
    
    public void remove() {
        ThreadLocalMap m = getMap(Thread.currentThread());
        if (m != null) {
            m.remove(this);
        }
    }

    ThreadLocalMap getMap(Thread t) {
        return t.threadLocals;
    }
    
    void createMap(Thread t, T firstValue) {
        t.threadLocals = new ThreadLocalMap(this, firstValue);
    }

    static class ThreadLocalMap {

        static class Entry extends WeakReference<java.lang.ThreadLocal<?>> {
            Object value;

            Entry(java.lang.ThreadLocal<?> k, Object v) {
                super(k);
                value = v;
            }
        }
    }
}

 

ThreadLocal은 ThreadLocalMap을 가지고있고 여기에서 key, value로 데이터를 보관합니다.

그리고 이때 get()메소드에서 Thread.currentThread()를 사용해 Thread를 꺼내고 그 ThreadLocalMap을 반환해서 가져오게됩니다.

 

 

이제 개념을 알았으니 어디에서 사용하고 있는지 간단하게 알아보겠습니다.

 

 

 

사용 사례

 

Spring Security

public class SecurityContextHolder {

    // ... 코드 생략

    private static void initializeStrategy() {
        if ("MODE_PRE_INITIALIZED".equals(strategyName)) {
            Assert.state(strategy != null, "When using MODE_PRE_INITIALIZED, setContextHolderStrategy must be called with the fully constructed strategy");
        } else {
            if (!StringUtils.hasText(strategyName)) {
                strategyName = "MODE_THREADLOCAL"; // ThreadLocal 전략이 Default
            }

            if (strategyName.equals("MODE_THREADLOCAL")) {
                strategy = new ThreadLocalSecurityContextHolderStrategy();
            } else if (strategyName.equals("MODE_INHERITABLETHREADLOCAL")) {
                strategy = new InheritableThreadLocalSecurityContextHolderStrategy();
            } else if (strategyName.equals("MODE_GLOBAL")) {
                strategy = new GlobalSecurityContextHolderStrategy();
            } else {
                try {
                    Class<?> clazz = Class.forName(strategyName);
                    Constructor<?> customStrategy = clazz.getConstructor();
                    strategy = (SecurityContextHolderStrategy)customStrategy.newInstance();
                } catch (Exception ex) {
                    ReflectionUtils.handleReflectionException(ex);
                }

            }
        }
    }
final class ThreadLocalSecurityContextHolderStrategy implements SecurityContextHolderStrategy {
    private static final ThreadLocal<Supplier<SecurityContext>> contextHolder = new ThreadLocal();
    
    // ... 코드 생략
}

 

 

 

 

Transaction

public class DataSourceTransactionManager extends AbstractPlatformTransactionManager implements ResourceTransactionManager, InitializingBean {

    // ... 코드 생략
    
    protected Object doGetTransaction() {
        DataSourceTransactionObject txObject = new DataSourceTransactionObject();
        txObject.setSavepointAllowed(this.isNestedTransactionAllowed());
        ConnectionHolder conHolder = (ConnectionHolder)TransactionSynchronizationManager.getResource(this.obtainDataSource());
        txObject.setConnectionHolder(conHolder, false);
        return txObject;
    }
    
    protected void doBegin(Object transaction, TransactionDefinition definition) {
    
        // ... 코드 생략
        if (txObject.isNewConnectionHolder()) {
            TransactionSynchronizationManager.bindResource(this.obtainDataSource(), txObject.getConnectionHolder());
        }    
        // ... 코드 생략
    }
}

public abstract class TransactionSynchronizationManager {
    private static final ThreadLocal<Map<Object, Object>> resources = new NamedThreadLocal("Transactional resources");
    private static final ThreadLocal<Set<TransactionSynchronization>> synchronizations = new NamedThreadLocal("Transaction synchronizations");
    private static final ThreadLocal<String> currentTransactionName = new NamedThreadLocal("Current transaction name");
    private static final ThreadLocal<Boolean> currentTransactionReadOnly = new NamedThreadLocal("Current transaction read-only status");
    private static final ThreadLocal<Integer> currentTransactionIsolationLevel = new NamedThreadLocal("Current transaction isolation level");
    private static final ThreadLocal<Boolean> actualTransactionActive = new NamedThreadLocal("Actual transaction active");
    
    // ... 코드 생략
}

 

 

 

Hibernate

public class ThreadLocalSessionContext extends AbstractCurrentSessionContext {
    private static final CoreMessageLogger LOG = (CoreMessageLogger)Logger.getMessageLogger(CoreMessageLogger.class, ThreadLocalSessionContext.class.getName());
    private static final Class<?>[] SESSION_PROXY_INTERFACES = new Class[]{Session.class, SessionImplementor.class, EventSource.class, LobCreationContext.class};
    private static final ThreadLocal<Map<SessionFactory, Session>> CONTEXT_TL = ThreadLocal.withInitial(HashMap::new);
    
    // ... 코드 생략
}

 

 

 

 

ThreadLocal 주의사항

 

메모리 누수

ThreadLocal 사용 후에는 반드시 remove()를 호출하여 메모리 누수를 방지해야 합니다. 특히 스레드 풀을 사용하는 환경에서는 더욱 중요합니다.

 

try {
    // ThreadLocal 사용
    userContext.set(new UserContext());
    // 비즈니스 로직
} finally {
    // 반드시 삭제
    userContext.remove();
}

가능하면 try-with-resources 패턴을 사용해 자원을 반납하면 안전하게 사용할 수 있습니다.

 

성능 고려사항

  1. ThreadLocal은 각 스레드마다 별도의 메모리를 사용합니다.
  2. 많은 수의 ThreadLocal 변수를 사용하면 메모리 사용량이 증가할 수 있습니다.
  3. get()과 set() 연산은 매우 빠르지만, 너무 빈번한 접근은 피하는 것이 좋습니다.

 

static final 선언

static final로 선언하는걸 권장합니다. 위에 사용 사례를 보시면 모두 static final로 선언되어있는걸 보실 수 있습니다.

 

  • static 사용 이유
    • ThreadLocal 객체 자체는 모든 스레드가 공유해도 됩니다
    • 각 스레드마다 값을 따로 저장하는 것은 ThreadLocal의 내부 구현이 처리함
    • 불필요한 인스턴스 생성을 방지할 수 있음
    • 전역적으로 접근이 필요한 경우가 많음 (예: Transaction, Security 등)
  • final 사용 이유
    • ThreadLocal 인스턴스 자체는 변경될 필요가 없음
    • 한 번 생성된 후에는 참조가 변경되면 안됨
    • 실수로 ThreadLocal 참조를 바꾸는 것을 방지
    • 불변성을 보장하여 스레드 안전성을 높임

 

 

 

끝!

이전글

 

[Spring Security] 스프링 시큐리티 이해하기 (1)

Spring SecuritySpring Security는 인증, 인가 및 일반적인 공격에 대한 보호를 제공하는 프레임워크 입니다. 서비스를 개발할때 필수적으로 구현해야하는것이 로그인, 회원가입과 권한인데요. 이를 Sprin

tmd8633.tistory.com

 

이전글에서 Spring Security 기본개념과 구조이해를 했습니다. 이번 글에서는 Spring Security를 적용해보도록 하겠습니다.

 

 

dependencies

build.gradle에 의존성을 주입해줍니다.

implementation 'org.springframework.boot:spring-boot-starter-security'
testImplementation 'org.springframework.security:spring-security-test'

implementation 'org.springframework.boot:spring-boot-starter-thymeleaf'

 

테스트 환경을 위해 타임리프도 추가했습니다.

 

 

 

시작하기

<!-- index.html -->
<!DOCTYPE html>
<html xmlns:th="http://www.thymeleaf.org">
<head>
    <meta charset="UTF-8">
    <title>Title</title>
</head>
<body>
    <span>메인페이지</span>
</body>
</html>

 

@Controller
public class MainController {

    @GetMapping
    public String mainPage() {
        return "index";
    }
}

 

이렇게 실행하고 접속해보면

 

 설정하지 않은 로그인 페이지가 뜨고, /login 으로 이동되었습니다. 이는 스프링 시큐리티에서 웹 보안에 가장 기본적인 로그인기능을 지원하기 때문에 나오는건데요.

 

로그에 표시된 password와 username에 'user' 라고 적고 로그인 해봅시다.

 

localhost:8080

성공 적으로 메인페이지로 넘어왔습니다. 우리는 기본적으로 제공하는 로그인 페이지를 사용하지 않고 로그인을 구현할것이기 때문에 일단 모든 요청을 허용해놓겠습니다.

 

@Configuration
@EnableWebSecurity
public class SecurityConfig {

    @Bean
    SecurityFilterChain defaultSecurityFilterChain(HttpSecurity http) throws Exception {

        http.authorizeHttpRequests(request ->
                // 어떤요청이든(anyRequest) 모두허용(permitAll)하겠다.
                request.anyRequest().permitAll()
            );

        return http.build();
    }

}

 

 

 

로그인

 

Security LoginForm 적용 전

 

HTML

<!-- index.html -->
<!DOCTYPE html>
<html lang="en">
<head>
    <meta charset="UTF-8">
    <title>Title</title>
</head>
<body>
    <span>메인페이지</span>

    <a href="/join">로그인</a>
    <a href="/signup">회원가입</a>
</body>
</html>
<!-- joinForm.html -->
<!DOCTYPE html>
<html xmlns:th="http://www.thymeleaf.org">
<head>
    <meta charset="UTF-8">
    <title>Title</title>
</head>
<body>
    <form th:action="@{/join}" th:method="post">
        <input type="text" name="username">
        <input type="password" name="password">
        <input type="submit" value="로그인">
    </form>
</body>
</html>
<!-- signupForm.html -->
<!DOCTYPE html>
<html xmlns:th="http://www.thymeleaf.org">
<head>
    <meta charset="UTF-8">
    <title>Title</title>
</head>
<body>
    <form th:action="@{/signup}" method="post">
        <input type="text" name="username">
        <input type="password" name="password">
        <input type="text" name="name">
        <input type="submit" value="회원가입">
    </form>
</body>
</html>
<!-- userForm.html -->
<!DOCTYPE html>
<html xmlns:th="http://www.thymeleaf.org">
<head>
    <meta charset="UTF-8">
    <title>Title</title>
</head>
<body>
    <span th:text="${user + '님 안녕하세요'}"></span>
</body>
</html>

메인페이지, 로그인, 회원가입, 유저페이지를 구성했습니다.

유저페이지는 로그인 한 유저의 정보를 볼 수 있는 페이지입니다.

 

Domain

@Getter @Setter
@ToString
public class User {

    private String username;
    private String password;
    private String role;
    private String name;
}

User에는 아이디, 비밀번호, 권한, 이름을 저장합니다.

 

 

MainController

@Controller
public class MainController {

    @GetMapping
    public String mainPage() {
        return "index";
    }

    @GetMapping("/user")
    public String userPage(@SessionAttribute("user") String username, Model model) {
        model.addAttribute("user", username);
        return "userForm";
    }

    @GetMapping("/join")
    public String loginPage() {
        return "joinForm";
    }

    @GetMapping("/signup")
    public String signupPage() {
        return "signupForm";
    }
}

HTML과 매핑해줍니다.

 

UserController

@Controller
@RequiredArgsConstructor
public class UserController {

    private final MemoryDB db;
    private final BCryptPasswordEncoder encoder;

    @PostMapping("/signup")
    public String signup(User user) {
        user.setRole("ROLE_USER");
        db.save(user);
        return "redirect:/join";
    }

    @PostMapping("/join")
    public String login(HttpServletRequest request, User user) {
        User findUser = db.find(user.getUsername());
        if (findUser == null || !encoder.matches(user.getPassword(), findUser.getPassword())) {
            return "redirect:/join";
        }
        request.getSession().setAttribute("user", findUser.getUsername());
        return "redirect:/user";
    }
}

로그인과 회원가입 POST 요청을 처리합니다.

 

DB

@Component
@RequiredArgsConstructor
public class MemoryDB {

    private final Map<String, User> db = new HashMap<>();
    private final BCryptPasswordEncoder encoder;

    public User save(User user) {
        user.setPassword(encoder.encode(user.getPassword()));
        db.put(user.getUsername(), user);
        return user;
    }

    public User find(String username) {
        return db.get(username);
    }
}

DB에 유저정보를 저장하고 간단한 예제 구현을 위해 Key값을 아이디로 잡았습니다.

 

 

Config

    @Bean
    BCryptPasswordEncoder bCryptPasswordEncoder() {
        return new BCryptPasswordEncoder();
    }

비밀번호는 암호화를 위해 BCryptPasswordEncode를 사용했습니다.

 

이렇게 하면 잘 동작할겁니다. 실제로는 예외처리등 수 많은 로직이 존재할겁니다. 이걸 Spring Security에게 위임해보겠습니다.

 

 

 

Security LoginForm 적용 후

먼저 UserController에서 우리가 로그인을 직접 구현했던 @PostMapping("/join") 부분을 제거해줍니다.

@Controller
@RequiredArgsConstructor
public class UserController {

    private final MemoryDB db;
    private final BCryptPasswordEncoder encoder;

    @PostMapping("/signup")
    public String signup(User user) {
        user.setRole("ROLE_USER");
        db.save(user);
        return "redirect:/join";
    }

}

 

 

@RequiredArgsConstructor
public class PrincipalDetails implements UserDetails {

    private final User user;

    @Override
    public Collection<? extends GrantedAuthority> getAuthorities() {
        return List.of(new SimpleGrantedAuthority(user.getRole()));
    }

    @Override
    public String getPassword() {
        return user.getPassword();
    }

    @Override
    public String getUsername() {
        return user.getUsername();
    }

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

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

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

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

 

UserDetails 를 구현해줍니다. 여기에 우리가 구현한 User를 멤버변수로 받고 getUsername과 getPassword를 이어줍니다.

나머지는 일단 true로 둡시다.

 

@Service
@RequiredArgsConstructor
public class PrincipalDetailsService implements UserDetailsService {

    private final MemoryDB db;

    @Override
    public UserDetails loadUserByUsername(String username) throws UsernameNotFoundException {
        User findUser = db.find(username);
        if (findUser == null) {
            throw new UsernameNotFoundException("아이디 또는 비밀번호가 일치하지 않습니다.");
        }
        return new PrincipalDetails(findUser);
    }
}

 

UserDetailsService를 구현하고 DB와 연동해줍니다. loadUserByUsername의 반환값은 위에서 구현한 PrincipalDetails로 해주고 User 객체를 넣어줍니다.

 

 

    @Bean
    SecurityFilterChain defaultSecurityFilterChain(HttpSecurity http) throws Exception {

        http.formLogin(login -> login
            .loginPage("/join")
            .loginProcessingUrl("/join")
            .defaultSuccessUrl("/user")
        );

        http.authorizeHttpRequests(request ->
            request.anyRequest().permitAll()
        );

        return http.build();
    }

SecurityConfig에서 formLogin을 작성해줍니다. formLogin().authorizeHttpRequests() 이렇게 이을 수 있는데 전 따로 분리하는게 더 보기 편하더라구요.

 

메소드 설명
loginPage 로그인 페이지 URL 설정
loginProcessingUrl 로그인 처리 URL 설정 (form action 값)
defaultSuccessUrl 로그인 성공 시 이동할 URL, 두번째 인자인 boolean alwaysUse false가 default
false : 사용자가 보호된 페이지 접근 시도 후 로그인 → 시도했던 페이지로 이동
true : 무조건 defaultSuccessUrl로 설정된 URL로 이동
failureUrl 로그인 실패 시 이동할 URL
usernameParameter 아이디 파라미터명 설정, username이 default
form input name이 username이 아닌경우 (예, email, account 등) 설정
passwordParameter 비밀번호 파라미터명 설정, password가 default
successHandler 로그인 성공 핸들러
failureHandler 로그인 실패 핸들러
disabled 사용안함

 

 

이렇게 설정하고

    @GetMapping("/user")
    public String userPage(@AuthenticationPrincipal PrincipalDetails principalDetails, Model model) {
        model.addAttribute("user", principalDetails.getUser().getName());
        return "userForm";
    }

SessionAttribute를 제거하고 SecurityContextHolder에 저장된 인증된 사용자의 데이터를 호출하는 @AuthenticationPrincipal을 이용해 데이터를 불러옵니다.

회원가입

 

userForm

로그인 로직을 직접구현하지 않고도 정상적으로 작동되었습니다.

 

 

 

권한설정

여기서 문제가 있습니다.

 

로그인을 하지 않고 /user 로 접속하니 500 에러가 발생했습니다.

java.lang.NullPointerException: Cannot invoke "com.security.demo.config.security.PrincipalDetails.getUser()" because "principalDetails" is null

 

PrincipalDetails에 user를 넣는과정에서 user = null 이 되었기때문에 문제가 발생한건데요. null check를 한다고해도 

로그인하지 않은 사용자가 접근이 가능하다는 것입니다. 이 문제를 해결해보도록 하겠습니다.

User 객체에는 Role 이라는 데이터가 있었습니다. 그리고 회원가입한 User는 "ROLE_USER" 를 부여받습니다. 이 데이터를 이용하겠습니다.

 

몇 가지 가정을 해보겠습니다.

  1. 권한에는 일반 유저("USER")와 관리자("ADMIN") 이 있습니다.
  2. 유저정보를 볼 수 있는 '/user' 는 일반유저와 관리자가 접근할 수 있고,
    관리자정보를 볼 수 있는 '/admin'은 관리자만 접근할 수 있습니다.

 

먼저 관리자 페이지를 간단하게 만들겠습니다.

<!-- adminForm.html -->
<!DOCTYPE html>
<html xmlns:th="http://www.thymeleaf.org">
<head>
    <meta charset="UTF-8">
    <title>Title</title>
</head>
<body>
    <span th:text="${admin + '관리자님 안녕하세요'}"></span>
</body>
</html>
    @GetMapping("/admin")
    public String adminPage(@AuthenticationPrincipal PrincipalDetails principalDetails, Model model) {
        model.addAttribute("admin", principalDetails.getUser().getName());
        return "adminForm";
    }

 

 

이제 SecurityConfig를 수정해보겠습니다.

    @Bean
    SecurityFilterChain defaultSecurityFilterChain(HttpSecurity http) throws Exception {

        http.formLogin(login -> login
            .loginPage("/join")
            .loginProcessingUrl("/join")
            .defaultSuccessUrl("/user")
            .failureUrl("/join?error")
        );

        http.authorizeHttpRequests(request -> request
            .requestMatchers("/user/**").hasAnyRole("USER", "ADMIN")
            .requestMatchers("/admin/**").hasRole("ADMIN")
            .anyRequest().permitAll()
        );

        return http.build();
    }

 

/user/** 에 접근하려면 "USER" 또는 "ADMIN" 권한이 존재해야합니다.

/admin/** 에 접근하려면 "ADMIN" 권한이 존재해야합니다.

 

이 권한 PrincipalDetails에 getAuthorities() 에 구현한 데이터를 기반으로 동작합니다. 그런데 우리는 "ROLE_USER" 를 넣었지 "USER"를 넣지는 않았습니다. 그 이유는 GrantedAuthority 기본 규칙은 "ROLE_" 접두사를 사용하는것이 default 이기 때문입니다.

 

조금 더 알고싶거나 "ROLE_" 접두사는 변경하고싶으면

 

 

[Spring Security] 스프링 시큐리티 이해하기 (1)

Spring SecuritySpring Security는 인증, 인가 및 일반적인 공격에 대한 보호를 제공하는 프레임워크 입니다. 서비스를 개발할때 필수적으로 구현해야하는것이 로그인, 회원가입과 권한인데요. 이를 Sprin

tmd8633.tistory.com

이 글 4. Authorization (인가) 부분을 참고하시기 바랍니다.

 

메소드 설명
requestMatchers(String... patterns)
requestMatchers(HttpMethod method, String... patterns)
requestMatchers(HttpMethod method)
특정 URL 패턴에 대한 접근 설정
requestMatchers("/user/**") : /user 하위 URL에 대한 접근 설정
requestMatchers(HttpMethod.GET, "/user"/**") : /user 하위 URL에 GET 접근에 대한 설정
requestMatchers(HttpMethod.GET) : GET 접근에 대한 설정
authenticated() 인증된 사용자만 접근 허용
permitAll() 모든 사용자 접근 허용
denyAll() 모든 접근 거부
hasAuthority(String authority) 특정 권한을 가진 사용자만 접근
hasAuthority("USER") -> "USER" 접근
hasAnyAuthority(String... authorities) 여러 권한 중 하나라도 가진 사용자만 접근
hasRole(String role) 특정 권한을 가진 사용자만 접근 (ROLE_ 접두사 자동 추가)
hasRole("USER") -> "ROLE_USER" 에 접근
hasAnyRole(String... roles) 여러 역할 중 하나라도 가진 사용자 접근 (ROLE_ 접두사 자동 추가)

 

 

결과

로그인이 되어있지 않은 유저가 /user 로 접근하는 경우 /join 으로 redirect되어 로그인이 진행된 후에 /user 접근이 됩니다.

만약 USER가 ADMIN에 접근하려는 경우 403 Forbidden 이 발생합니다.

 

 

로그아웃

<!-- userForm.html -->
<!DOCTYPE html>
<html xmlns:th="http://www.thymeleaf.org">
<head>
    <meta charset="UTF-8">
    <title>Title</title>
</head>
<body>
    <span th:text="${user + '님 안녕하세요'}"></span>
    <form th:action="@{/logout}" th:method="post">
        <input type="submit" th:value="로그아웃">
    </form>
</body>
</html>

userForm.html에서 로그아웃 form을 만들어줍니다.

 

        http.logout(logout -> logout
            .logoutUrl("/logout")
            .logoutSuccessUrl("/")
        );

http.logout을 설정해주면 로그아웃도 완성입니다.

 

로그아웃 설정

메소드 설명
logoutURL 로그아웃 처리 URL default : "/logout"
logoutSuccessUrl 로그아웃 성공 시 이동할 URL
logoutSuccessHandler 로그아웃 성공 시 핸들러
addLogoutHandler 로그아웃 핸들러 추가
clearAuthentication SecurityContext에서 인증정보 제거 default : true
invalidateHttpSession 세션 무효화 default true
deleteCookies(String... cookieNamesToClear) 삭제할 쿠키 지정
logoutRequestMatcher 로그아웃 요청 매처 설정
예) GET으로 로그아웃 요청 시 .logoutRequestMatcher(new AntPathRequestMatcher("/logout", "GET"))

 

 

결론

Spring Security에서 가장 기본적으로 다루는 부분을 구현해보았습니다.  아직 알아야할거도 많고 추가된것들도 많아서 공부를 계속해 나가면서 시리즈 더 추가해보겠습니다.

 

CSRF란?

CSRF Cross-Site Request Forgery의 약자로 인증된 사용자의 권한을 악용하여 해당 사용자가 의도하지 않은 요청을 웹사이트에 전송하는 공격 기법입니다. 공격자는 사용자가 이미 인증된 상태를 악용하여 사용자의 의도와는 무관한 작업을 수행하게 만듭니다. 다시 말해 인증된 요청과 위조된 요청을 구분하지 못하고 서버에서 요청을 처리하여 문제가 생기는 것을 말하는데요. 웹 개발자라면 반드시 알아야하는 부분입니다. 오늘은 이것에 대해서 알아보도록 하겠습니다.

 

 

 

CSRF 공격

CSRF 공격에 대해서 예시와 함께 알아보도록하겠습니다.

 

예시로 은행 웹사이트에서 로그인한 사용자로부터 돈을 이체할 수 있는 Form이 있다고 가정하겠습니다.

<form method="post" action="/transfer">
    <input type="text" name="amount"/>
    <input type="text" name="account"/>
    <input type="submit" value="Transfer"/>
</form>

HTTP 요청은 다음과 같습니다.

POST /transfer HTTP/1.1
Host: bank.jours.com
Cookie: JSESSIONID=randomid
Content-Type: application/x-www-form-urlencoded

amount=10000&account=7654

 

 

다음은 은행 웹사이트에서 로그아웃하지 않고 위조된 웹사이트를 방문한다고 가정하겠습니다. 그 웹사이트에는 다음과 같은 HTML이 있습니다.

<form method="post" action="https://bank.jours.com/transfer">
    <input type="hidden" name="amount" value="100000"/>
    <input type="hidden" name="account" value="1234"/> <!-- 공격자 계좌번호 -->
    <input type="submit" value="Win Money!"/>
</form>

 

위조된 웹사이트에서 submit을 하면 공격자 계좌로 송금이 될겁니다. 이는 위조된 웹사이트가 사용자의 쿠키를 볼 수 없지만 은행과 관련된 쿠키는 여전히 남아 요청과 함께 전송되기 때문에 발생합니다. 더욱 큰 문제는 버튼을 클릭해 submit 하지 않아도 JavaScript를 사용하여 자동화하여 제출할 수 있다는 것입니다. 그렇다면 어떻게 이 문제를 해결할 수 있을까요?

 

 

CSRF 방어

 

읽기전용 메소드

CSRF를 방어하기위해서는 읽기전용 메소드가 선행되어야 합니다.

HTTP Method중 GET, HEAD, OPTIONS, TRACE 메소드는 반드시 읽기전용 메소드가 되어야합니다.

// 잘못된 예시 - GET으로 데이터 변경
@GetMapping("/user/delete/{id}")  // ❌ 절대 하면 안 됨
public void deleteUser(@PathVariable Long id) {
    userService.deleteUser(id);
}

// 올바른 예시 - POST로 데이터 변경
@PostMapping("/user/delete/{id}")  // ✅ 올바른 방법
public void deleteUser(@PathVariable Long id) {
    userService.deleteUser(id);
}

 

 

1. Synchrozier Token Pattern

form 안에 CSRF 토큰을 넣어주는겁니다. 그러면 서버는 토큰을 조회하여 값이 일치하지 않으면 요청을 거부할 수 있게됩니다. 핵심은 쿠키는 브라우저에서 자동으로 HTTP 요청에 포함되지만 CSRF 토큰이 브라우저에서 자동으로 포함되지 않는다는 것입니다.

 

<form method="post" action="/transfer">
    <input type="text" name="amount"/>
    <input type="text" name="account"/>
    <input type="hidden" name="_csrf" value="4bfd1575-3ad1-4d21-96c7-4ef2d9f86721"/>
    <input type="submit" value="Transfer"/>
</form>

 

HTTP 요청은 다음과 같습니다.

POST /transfer HTTP/1.1
Host: bank.jours.com
Cookie: JSESSIONID=randomid
Content-Type: application/x-www-form-urlencoded

amount=100000&account=7654&_csrf=4bfd1575-3ad1-4d21-96c7-4ef2d9f86721

 

<!-- Thymeleaf 에서 @{} 를 사용하면 자동으로 CSRF 토큰이 포함됨 -->
<form th:action="@{/login}" method="post">

 

 

SameSite

쿠키에 SameSite 속성을 지정하는 것입니다. 서버는 SameSite 속성을 지정하여 외부 사이트에서 오는 쿠키를 보낼지 여부를 정할 수 있습니다.

 

 

설정값 설명
Strict
  • 가장 엄격한 설정
  • 같은 도메인의 요청에서만 쿠키 전송
  • 외부 사이트에서의 모든 요청에 쿠키를 보내지 않음
  • 보안성은 가장 높지만, 사용자 경험을 해칠 수 있음
Lax
  • TOP-level 네비게이션(주소창에 직접 입력, <a> 태그 클릭)에서는 쿠키 전송 허용
  • POST, PUT, DELETE 등의 요청에서는 쿠키 전송 제한
  • Strict보다 유연하면서도 기본적인 보안 제공
None
  • 모든 크로스 사이트 요청에 쿠키 전송 허용
  • 반드시 Secure 플래그와 함께 사용해야 함
  • HTTPS가 필수

 

 

# application.properties
# strict, lax, none 중 설정
server.servlet.session.cookie.same-site=strict

 

Spring Boot 2.6.0 이상에서는 SameSite=Lax 가 Default입니다.

 

SameSite 주의점

  • 브라우저가 SameSite를 지원하지 않을 수 있습니다. 예전 브라우저를 사용한다면 SameSite가 지원되지 않을 수 있습니다.
  • strict 설정 시  social.jours.com - email.jours.com 과의 쿠키는 전송되지않습니다.

 

 

 

REST API의 CSRF 설정

출처 : https://docs.spring.io/spring-security/reference/features/exploits/csrf.html#csrf-protection-read-only

 

REST API에서는 CSRF를 disabled 해도 괜찮다고합니다. 왜냐하면 API 요청시 인증정보(Jwt, OAuth2, Client Key 등)를 포함하여 전송하기 때문에 불필요하게 CSRF 인증정보를 저장하지 않아도 되는 것입니다.

 

 

 

Spring Security 에서의 CSRF 설정

Spring Security 에서 CSRF를 설정할 수 있습니다. 기본적으로 CSRF 토큰을 지원합니다.

    @Bean
    SecurityFilterChain defaultSecurityFilterChain(HttpSecurity http) throws Exception {

        http.csrf(Customizer.withDefaults());

        return http.build();
    }

 

csrf 설정 메소드

http.csrf(csrf -> {
    csrf
        // CSRF 완전 비활성화
        .disable()                     
        
        // 특정 경로 CSRF 검증 제외
        .ignoringAntMatchers("/api/**")  
        
        // RequestMatcher로 더 복잡한 조건으로 제외할 경로 설정
        .ignoringRequestMatchers(requestMatcher)  
        
        // CSRF 토큰 저장소 설정
        .csrfTokenRepository(CookieCsrfTokenRepository.withHttpOnlyFalse())
        
        // 커스텀 CSRF 토큰 저장소 설정
        .csrfTokenRepository(new CustomCsrfTokenRepository())
        
        // CSRF 토큰 생성 요청 처리 경로 설정 (기본값: "_csrf")
        .csrfTokenRequestHandler(requestHandler)
        
        // 세션 속성 이름 설정 (기본값: "CSRF_TOKEN")
        .sessionAuthenticationStrategy(sessionAuthenticationStrategy)
        
        // CSRF 토큰 필터 이전에 실행될 필터 추가
        .requireCsrfProtectionMatcher(requestMatcher)
});
.csrfTokenRepository(CookieCsrfTokenRepository.withHttpOnlyFalse()) 는 JavaScript에서 CSRF 토큰을 사용 할 수 있도록 쿠키에 저장하는 것인데, 위에서 설명했듯이 API는 csrf.disabled() 해서 사용하는 것이 더 유용할 수 있습니다.

 

 

결론

CSRF 공격은 웹 애플리케이션의 중요한 보안 위협이지만, 적절한 방어 메커니즘을 구현함으로써 효과적으로 방어할 수 있습니다. 특히 CSRF 토큰, SameSite 쿠키 설정, 그리고 적절한 헤더 검증을 조합하여 사용하는 것이 권장됩니다.

'일반 > CS' 카테고리의 다른 글

[CS][Spring Security] CORS에 대해서 알아보자  (0) 2024.12.16
Maven Central Repository에 라이브러리 등록하기  (0) 2024.09.19
[CS] MVC 패턴  (0) 2024.04.17
HTTP GET과 POST 차이  (0) 2024.01.25
URI와 URL의 차이점 (Feat : URN)  (0) 2024.01.21

+ Recent posts