개요

저번 글에서 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를 구현해 사용하는 것도 방법입니다.

+ Recent posts