본문 바로가기

Spring Boot/Security 쿠키,세션 기반 인증,인가

동시 세션 제어와 세션 고정 보호 : 동시 세션 제어

 - 동시 세션 제어의 개념, 필요성

 - Spring Security에서 제공하는 동시 세션 제어 기능

 - maximumSessions() 와 maxSessionsPreventsLogin() 설정의 차이

 - 세션 만료 전략(이전 세션 만료 vs 새 로그인 차단)을 비교하기

 - 직접 동시 세션 제어 설정을 적용하고 테스트하기


동시 세션 제어

웹 애플리케이션은 기본적으로 하나의 계정으로 여러 기기나 브라우저에 동시에 접속할 수 있다

하지만, 보안상 또는 비즈니스 정책상 이를 제한할 필요가 있다

 

  • 은행, 금융 서비스 - > 하나의 계정이 여러곳에서 동시에 로그인하면 위험함
  • 온라인 시험 시스템 - > 하나의 계정이 여러 기기에서 동시 시험 응시 불가

따라서, 동일 사용자의 동시 세션수를 제어하는 기능이 필요하다

 

 

1. 동시 세션 제어의 필요성

  • 보안 강화 : 계정 공유, 세션 탈취 등 방지
  • 정책 준수 : 특정 서비스의 이용약관(1인 1계정 원칙 등등)
  • 리소스 절약 : 불필요한 세션 사용을 줄여서 서버 부하를 완화하기

Spring Security에서의 동시 세션 제어

@Configuration
@EnableMethodSecurity(prePostEnabled = true)
public class SecurityConfig {
    @Bean
    @Order(1)
    public SecurityFilterChain meFilterChain(HttpSecurity http) throws Exception {
        http
                .securityMatcher("/me/**")
                .csrf(csrf -> csrf.disable())
                
                // 이 부분
                .sessionManagement(session -> session
                        .maximumSessions(1) // 최대 1개의 세션만 허용하기
                        // 새로운 로그인에 대해 차단 여부 설정, 기본 false
                        .maxSessionsPreventsLogin(true)
                )
                .authorizeHttpRequests(auth -> auth
                        .requestMatchers("/me/**").authenticated()
                        .anyRequest().authenticated()
                )
                .httpBasic(Customizer.withDefaults());
        return http.build();
    }   
 }

 

 

1. maximumSessions()

동일 사용자가 동시에 가질 수 있는 세션의 최대 갯수를 지정한다

  • maximumSessions(1) : 동일 사용자에 대한 세션 최대 갯수 1개
  • maximumSessions(2) : 2개

 

 

2. maxSessionsPreventsLogin()

최대 세션 수를 초과했을 때 동작 방식을 정의함

  • true : 새로운 로그인 시도를 차단한다 (기존 세션 유지)
  • false : 새로운 로그인을 허용하고, 기존 세션 중 하나를 만료시킨다

 

 

3. 동작 흐름 다이어그램

사용자 로그인 시도에 대해 세션수가 maximumSessions()의 현재 세션 수를 확인하고 로그인 허용 또는 maxSessionsPreventsLogin에 따라 새로운 로그인을 차단하던, 허용하던 한다

 


동시 세션 제어 적용해보기

https://github.com/B1uffer/multiSessionTest

 

build.gradle

plugins {
    id 'java'
    id 'org.springframework.boot' version '3.5.14'
    id 'io.spring.dependency-management' version '1.1.7'
}

group = 'com.b1uffer'
version = '0.0.1-SNAPSHOT'
description = 'multiSessionTest'

java {
    toolchain {
        languageVersion = JavaLanguageVersion.of(21)
    }
}

repositories {
    mavenCentral()
}

dependencies {
    implementation 'org.springframework.boot:spring-boot-starter-data-jpa'
    implementation 'org.springframework.boot:spring-boot-starter-security'
    implementation 'org.springframework.boot:spring-boot-starter-thymeleaf'
    implementation 'org.springframework.boot:spring-boot-starter-web'
    implementation 'org.thymeleaf.extras:thymeleaf-extras-springsecurity6'
    compileOnly 'org.projectlombok:lombok'
    runtimeOnly 'com.h2database:h2'
    annotationProcessor 'org.projectlombok:lombok'
    testImplementation 'org.springframework.boot:spring-boot-starter-test'
    testImplementation 'org.springframework.security:spring-security-test'
    testCompileOnly 'org.projectlombok:lombok'
    testRuntimeOnly 'org.junit.platform:junit-platform-launcher'
    testAnnotationProcessor 'org.projectlombok:lombok'
}

tasks.named('test') {
    useJUnitPlatform()
}

 

* Thymeleaf : 만료 리다이렉션 페이지와 커스텀 로그인 페이지를 간단히 만들기 위해 사용함

(뷰 엔진은 다른 것으로 대체해도 된다)

 

 

사용자 계정 구성(InMemory)

import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.security.core.userdetails.User;
import org.springframework.security.core.userdetails.UserDetails;
import org.springframework.security.provisioning.InMemoryUserDetailsManager;

@Configuration
public class InMemoryUsers {

    @Bean
    public InMemoryUserDetailsManager userDetailsService() {
        UserDetails user = User.withUsername("user")
                .password("{noop}password")
                .roles("USER")
                .build();

        UserDetails user2 = User.withUsername("user2")
                .password("{noop}password")
                .roles("USER")
                .build();

        UserDetails admin = User.withUsername("admin")
                .password("{noop}password")
                .roles("ADMIN")
                .build();
        return new InMemoryUserDetailsManager(user, user2, admin);
    }
}

 

 

 

 

Security 설정 (동시 세션 제어 핵심)

import org.springframework.boot.autoconfigure.security.servlet.PathRequest;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.core.annotation.Order;
import org.springframework.security.config.annotation.web.builders.HttpSecurity;
import org.springframework.security.config.annotation.web.configuration.EnableWebSecurity;
import org.springframework.security.config.http.SessionCreationPolicy;
import org.springframework.security.core.session.SessionRegistry;
import org.springframework.security.core.session.SessionRegistryImpl;
import org.springframework.security.web.SecurityFilterChain;

@Configuration
@EnableWebSecurity
public class SecurityConfig {
    // 동시 세션 제어 핵심
    @Bean
    @Order(1)
    public SecurityFilterChain sessionFilterChain(HttpSecurity http) throws Exception {
        http
                .authorizeHttpRequests(auth -> auth
                        .requestMatchers("/login","/session-expired", "/css/**").permitAll()
                        .anyRequest().authenticated()
                )
                .formLogin(form -> form
                        .loginPage("/login")
                        .defaultSuccessUrl("/", true)
                        .permitAll()
                )
                .sessionManagement(session -> session
                        .sessionCreationPolicy(SessionCreationPolicy.IF_REQUIRED)
                        .maximumSessions(1)
                        .maxSessionsPreventsLogin(false)
                        .expiredUrl("/session-expired")
                        .sessionRegistry(sessionRegistry())
                );
        return http.build();
    }

    @Bean
    @Order(0)
    public SecurityFilterChain h2FilterChain(HttpSecurity http) throws Exception {
        http
                .securityMatcher(PathRequest.toH2Console())
                .authorizeHttpRequests(auth -> auth
                        .requestMatchers(PathRequest.toH2Console()).permitAll()
                        .anyRequest().authenticated()
                )
                .csrf(csrf -> csrf.disable())
                .headers(headers -> headers
                        .frameOptions(frame -> frame.sameOrigin())
                );
        return http.build();
    }

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

 

* 주의 : 분산 환경 / 외부 무효화와의 정확한 동기화를 위해서는
추가 구성(예 : 이벤트 퍼블리셔, 세션 스토어 연동)이 필요할 수 있음. 해당 문서는 동시 세션 제어 핵심 로직에 집중한다

 

 

 

컨트롤러 / 뷰

import jakarta.servlet.http.HttpSession;
import org.springframework.security.core.Authentication;
import org.springframework.stereotype.Controller;
import org.springframework.ui.Model;
import org.springframework.web.bind.annotation.GetMapping;

@Controller
public class HomeController {

    @GetMapping("/")
    public String home(Model model, HttpSession session, Authentication auth) {
        // session 에서 id 가져옴
        model.addAttribute("sessionId", session.getId());
        // authentication 에서 name 가져옴
        model.addAttribute("username", auth.getName());
        // session 생성일자
        model.addAttribute("creationTime", session.getCreationTime());
        // session 최근접속시간
        model.addAttribute("lastAccessedTime", session.getLastAccessedTime());
        return "home";
    }

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

    @GetMapping("/session-expired")
    public String sessionExpired(Model model) {
        model.addAttribute("msg","세션이 만료되었습니다. 다시 로그인 해주세요.");
        return "session-expired";
    }
}

 

 

home.html

<!DOCTYPE html>
<html lang="ko" xmlns:th="<http://www.thymeleaf.org>">
<head>
    <meta charset="UTF-8" />
    <title>홈</title>
</head>
<body>
<h2>동시 세션 제어 실습 홈</h2>
<ul>
    <li>사용자: <span th:text="${username}"></span></li>
    <li>세션 ID: <code th:text="${sessionId}"></code></li>
    <li>생성 시각(ms): <span th:text="${creationTime}"></span></li>
    <li>마지막 접근 시각(ms): <span th:text="${lastAccessedTime}"></span></li>
</ul>
<a href="/logout">로그아웃</a>
</body>
</html>

 

 

login.html

<!DOCTYPE html>
<html lang="ko" xmlns:th="<http://www.thymeleaf.org>">
<head>
    <meta charset="UTF-8" />
    <title>로그인</title>
</head>
<body>
<h2>로그인</h2>
<form th:action="@{/login}" method="post">
    <div>
        <label>아이디</label>
        <input type="text" name="username" value="user" />
    </div>
    <div>
        <label>비밀번호</label>
        <input type="password" name="password" value="password" />
    </div>
    <button type="submit">로그인</button>
</form>
</body>
</html>

 

session-expired.html

<!DOCTYPE html>
<html lang="ko" xmlns:th="<http://www.thymeleaf.org>">
<head>
    <meta charset="UTF-8" />
    <title>세션 만료</title>
</head>
<body>
<h3 th:text="${msg}">세션이 만료되었습니다.</h3>
<a href="/login">다시 로그인</a>
</body>
</html>

 

 

  1. 브라우저 A(일반 창) - > http://localhost:8080 접속 - > 로그인(user/password) - > 홈 화면에서 세션ID 확인
  2. 브라우저 B(시크릿 / 사생활 보호창) - > 동일 계정(user) 로그인
    설정상 maxSessionPreventsLogin(false) 이므로 B 로그인은 성공함, 동시에 A 세션은 만료된다
  3. 브라우저 A에서 페이지 새로고침 - > /session-expired 로 이동되어 만료 안내 확인
  4. 동작 변경 실험 : maxSessionPreventsLogin(true) 로 변경 후 재시도
    B에서 새 로그인 시도가 거부되고(login?error), A의 세션은 유지된다(로그인 유지)

위 예제 로직

 

 

트러블 슈팅 체크리스트

증상 원인 해결
A가 만료되지 않음 같은 브라우저 프로파일 사용(쿠키 공유) 반드시 시크릿창 또는 다른 브라우저 사용
만료 후에도 홈 접근가능 브라우저 캐시 화면 강력 새로고침 / 네트워크 탭 확인
로그인 자체가 안됨 사용자 정보 미설정 InMemory 사용자 / 비밀번호 재확인
404 템플릿 템플릿 경로 / 확장자 오타 resources/templates/*.html 확인
session-expired 로 리다이렉트가 안됨 authenticated()에 걸림 requestMatchers("/session-expired")
.permitAll() 추가하기
sessionFilterChain과 h2FilterChain 충돌 Security FilterChain 순서 문제 구체적인 FilterChain(h2FilterChain)은 앞에 두고(@Order(0)),
추상적인 FilterChain, 가령 sessionFilterChain 와 같은 securityMatcher() 를 사용하지 않는 전역 FilterChain은 뒤에 두기(@Order(99))

 

 

 

  • 보안 민감 서비스는 mexSessionPreventsLogin(true) 로 계정 공유 방지하기
  • 분산 / 클러스터 환경에서는 세션 저장소 공유(예 : Redis)와 세션 이벤트 동기화를 함께 고려해야한다

세션 만료 전략

Spring Security 에서는 세션이 초과될 경우, 두가지 전략 중 하나를 선택할 수 있다

  • 이전 세션 만료(기본값)
  • 새 로그인 차단하기

 

1. 이전 세션 만료(기본값)

새로운 로그인은 허용하고, 기존 세션 중 하나를 강제로 만료시킴

                .sessionManagement(session -> session
                        .maximumSessions(1) // 동일 user 최대 세션 1개
                        .maxSessionsPreventsLogin(false) // 로그인은 허용, 기본값, 기존세션 만료

 

 

 

2. 새 로그인 차단하기

기존 세션은 유지하고, 새로운 로그인은 거부함

                .sessionManagement(session -> session
                        .maximumSessions(1) // 동일 user 최대 세션 1개
                        .maxSessionsPreventsLogin(true) // 세션 유지중 로그인 거부, 기존 세션 유지

 

 

 

  • 보안 민감 서비스(금융, 공공기관) - > maxSessionPreventsLogin(true) 권장, 로그인 거부하기
  • 일반 서비스(커뮤니티, 쇼핑몰) - > maxSessionsPreventsLogin(false) 권장, 로그인은 허용하고 세션 만료
  • 테스트 방법 : 서로 다른 브라우저나 시크릿 모드에서 동일 계정 로그인 시도하기

항목 설명 기본값
maximumSessions(n) 허용 가능한 최대 동시 세션 수 제한없음
maxSessionsPreventsLogin(true) 초과시 새 로그인 거부 false
maxSessionsPreventsLogin(false) 초과시 기존 세션 만료 후 새 로그인 허용