본문 바로가기
Spring/Spring Security

세션관리 ( 동시접속 )

코동이 2021. 10. 14.

 

* ConcurrentSessionFilter

 

 

ConcurrentSessionFilter의 동시에 접속하는 세션들에 관심이 많다. 주요 역할은 SessionRegistry에서 SessionInformationexpiredtrue인 토큰이 들어오지 못하도록 감시하고 막는 역할을 한다. (마치 동시접속 제어 효과를 누린다.) Tomcat과 같은 servlet-container에서 제공하는 Session을 Spring이 제어 할 수 없다. tomcat이 넘겨주는 세션을 SessionRegistry에서 관리한다. 

 

특정 sessionIdexpiredtrue로 마킹하면, 더이상 접근하지 못하도록 막는 역할을 한다.

 

 

ConcurrentSessionFilter 클래스를 확인해서 작동방식을 알아본다. 위의 사진에서 보았던 방식대로 SessionInformation을 조회해서 만약 토큰이 만료되었다면, 로그아웃이 되도록 한다.

 

private void doFilter(HttpServletRequest request, HttpServletResponse response, FilterChain chain)
		throws IOException, ServletException {
	HttpSession session = request.getSession(false);
	if (session != null) {
		SessionInformation info = this.sessionRegistry.getSessionInformation(session.getId());
		if (info != null) {
			if (info.isExpired()) {
				// Expired - abort processing
				this.logger.debug(LogMessage
						.of(() -> "Requested session ID " + request.getRequestedSessionId() + " has expired."));
				doLogout(request, response);
				this.sessionInformationExpiredStrategy
						.onExpiredSessionDetected(new SessionInformationExpiredEvent(info, request, response));
				return;
			}
			// Non-expired - update last request date/time
			this.sessionRegistry.refreshLastRequest(info.getSessionId());
		}
	}
	chain.doFilter(request, response);
}

 

SessionRegistry가 어떤 경우에 토큰을 expired 시킬 것인지 중요하다. 이 작업은 SessionManagementFilter가 한다.

 

 

 

SessionManagementFilterSessionAuthenticationStrategy라는 인터페이스를 가지고 있는데 다양한 전략을 가질 수 있다. 크게 2가지로 분류하자면, 세션고정 필터인 AbstractSessionFixationProtectionStrategy, 동시 세션 접속 필터인 ConcurrentSessionControlAuthenticationStrategy이다. 

 

ConcurrentSessionControlAuthenticationStrategy에서는 동시접속을 1명 혹은 2명으로 제한하면, 이 필터에서 기존에 접속되어 있는 세션을 expired하고 새로운 세션을 인정 할 것인가? 아니면 새로운 세션의 접근을 막을 것인가? 선택하게 된다.

 

세션 동시접속은 총 1개만 가능하며, 1개를 넘어서서 새로 로그인을 하면 기존 세션이 만료되며, 만료된 세션은 다른 url로 redirect하는 방법은 다음과 같다.

 

 

...
.sessionManagement(s-> s
    .maximumSessions(1)
    .maxSessionsPreventsLogin(false)
    .expiredUrl("/session-expired")
)
...

 

 

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

 

SessionRegistry를 빈으로 등록하고 내부를 살펴본다. SessionRegistrySessionInformation을 가지고 있으며 SessionInformation이 가지는 칼럼은 다음과 같다.

 

public class SessionInformation implements Serializable {

	private static final long serialVersionUID = SpringSecurityCoreVersion.SERIAL_VERSION_UID;

	private Date lastRequest;

	private final Object principal;

	private final String sessionId;

	private boolean expired = false;

 

SessionRegistry도 여러개의 Session을 가질 수 있으며 principals들을 검사하여 조건에 따라 세션의 접근을 막는다. 실제 세션이 남아있더라도 SessionRegistry에서 expired시키면 Spring 안으로 session이 들어오지 못한다.

 

public class SessionRegistryImpl implements SessionRegistry, ApplicationListener<AbstractSessionEvent> {

	protected final Log logger = LogFactory.getLog(SessionRegistryImpl.class);

	// <principal:Object,SessionIdSet>
	private final ConcurrentMap<Object, Set<String>> principals;

	// <sessionId:Object,SessionInformation>
	private final Map<String, SessionInformation> sessionIds;

 

세션정보를 담고 있는 클래스를 생성한다

 

@Data
@AllArgsConstructor
@NoArgsConstructor
@Builder
public class SessionInfo {
    private String sessionId;
    private Date time;
}

 

세션정보 SessionInfo를 가지고 있는 사용자세션 클래스를 추가한다.

 

@Data
@AllArgsConstructor
@NoArgsConstructor
@Builder
public class UserSession {
    private String username;
    private List<SessionInfo> sessions;

    public int getCount() {
        return sessions.size();
    }
}

 

사용자의 모든 세션정보를 볼 수 있는 요청을 추가한다.

 

@GetMapping("/sessions")
public String sessions(Model model) {
    model.addAttribute("sessionList",sessionRegistry.getAllPrincipals().stream().map(p->
            UserSession.builder()
                    .username(((SpUser)p).getUsername())
                    .sessions(sessionRegistry.getAllSessions(p, false).stream().map(s->
                            SessionInfo.builder()
                                    .sessionId(s.getSessionId())
                                    .time(s.getLastRequest()).build())
                                    .collect(Collectors.toList()))
                    .build()).collect(Collectors.toList()));
    return "sessionList";
}

 

SessionRegistry에 등록된 모든 principal을 가져와서 UserSession 클래스로 만든다. 또한 UserSession 클래스의 세션정보는 SessionRegistry에서 모든 세션정보들을 조회해서 SessionInfo클래스로 만들어 넣는다.

 

토큰을 파기하는 요청을 추가한다.

 

@PostMapping("/sessions/expire")
public String expireSession(@RequestParam String sessionId) {
    SessionInformation sessionInformation = sessionRegistry.getSessionInformation(sessionId);
    if(!sessionInformation.isExpired()) {
        sessionInformation.expireNow();
    }
    return "redirect:/sessions";
}

 

sessionId를 통해 SeeionRegistry에 있는 SessionInformation를 조회한 다음, 강제로 파기시킨다.

 

 

소스를 좀 더 살펴보기 위해서 ConcurrentSessionControlAuthenticationStrategy를 살펴본다.

 

 

@Override
public void onAuthentication(Authentication authentication, HttpServletRequest request,
		HttpServletResponse response) {
	List<SessionInformation> sessions = this.sessionRegistry.getAllSessions(authentication.getPrincipal(), false);
	int sessionCount = sessions.size();
	int allowedSessions = getMaximumSessionsForThisUser(authentication);
	if (sessionCount < allowedSessions) {
		// They haven't got too many login sessions running at present
		return;
	}
	if (allowedSessions == -1) {
		// We permit unlimited logins
		return;
	}
	if (sessionCount == allowedSessions) {
		HttpSession session = request.getSession(false);
		if (session != null) {
			// Only permit it though if this request is associated with one of the
			// already registered sessions
			for (SessionInformation si : sessions) {
				if (si.getSessionId().equals(session.getId())) {
					return;
				}
			}
		}
		// If the session is null, a new one will be created by the parent class,
		// exceeding the allowed number
	}
	allowableSessionsExceeded(sessions, allowedSessions, this.sessionRegistry);
}

 

sessionRegistry에서 모든 세션정보를 조회한다.  expired되지 않은 것들을 가져온다. session이 등록되어있는 것의 숫자를 보고 사용자에게 허용된 세션이 몇개인지 확인하고 만약에 허용된 갯수가 초과했다면, 초과된 세션들을 가져와서 allowableSessionsExceeded를 실행한다.

 

protected void allowableSessionsExceeded(List<SessionInformation> sessions, int allowableSessions,
		SessionRegistry registry) throws SessionAuthenticationException {
	if (this.exceptionIfMaximumExceeded || (sessions == null)) {
		throw new SessionAuthenticationException(
				this.messages.getMessage("ConcurrentSessionControlAuthenticationStrategy.exceededAllowed",
						new Object[] { allowableSessions }, "Maximum sessions of {0} for this principal exceeded"));
	}
	// Determine least recently used sessions, and mark them for invalidation
	sessions.sort(Comparator.comparing(SessionInformation::getLastRequest));
	int maximumSessionsExceededBy = sessions.size() - allowableSessions + 1;
	List<SessionInformation> sessionsToBeExpired = sessions.subList(0, maximumSessionsExceededBy);
	for (SessionInformation session : sessionsToBeExpired) {
		session.expireNow();
	}
}

 

allowableSessionsExceeded 메서드는 세션들을 expire하는 메서드이다.

 

이상 해당 클래스로 세션의 동시접근을 제한한다.

 

 

* 세션ID를 항상 하나로 고정해버린다면??

 

.sessionManagement(s-> s
        .sessionFixation(SessionManagementConfigurer.SessionFixationConfigurer::none)
        .maximumSessions(1)
        .maxSessionsPreventsLogin(false)
        .expiredUrl("/session-expired")
);

 

로그인 시에도 기존 세션ID 값이 바뀌지 않는다면 어떤 문제가 생길까?

 

해커가 특정 사람의 세션ID를 탈취해서 본인 쿠키값에 심으면, 탈취한 사람의 계정으로 로그인이 가능하다. 특정 사람은 자신의 세션ID가 탈취당한지 모르고 고르인을 한다면, 해커도 해당 세션ID로 로그인을 하는 효과를 볼 수 있다.

 

따라서, 로그인시마다 세션값이 달라지도록 설정해야 한다.

 

.sessionManagement(s-> s
        //.sessionFixation(SessionManagementConfigurer.SessionFixationConfigurer::none)
        .sessionFixation(SessionManagementConfigurer.SessionFixationConfigurer::changeSessionId)
        .maximumSessions(1)
        .maxSessionsPreventsLogin(false)
        .expiredUrl("/session-expired")
);

 

none() 대신에 changeSessionId()를 설정한다. 따라서, 로그인시마다 새로운 세션ID가 발급되기 때문에 안전하다.

 

( 또한 세션 생성 정책으로 여러가지를 정할 수 있는데, Bearer Token을 사용하는 경우 STATELESS이다. )

 

.sessionManagement(s-> s
        .sessionCreationPolicy(SessionCreationPolicy.STATELESS)
);

 

극단적으로 SATETELESSALWAYS를 사용하는 경우가 있다. STATELESS의 경우 주의 할 것은, 세션 ID를 아예 안만드는 것이 아니라, 스프링 시큐리티 필터에서 세션ID를 사용하지 않고 유입을 막아버린다. 즉, 단지 세션 인증 방식을 사용하지 않는다는 의미지 세션 자체를 안만드는 것은 아니다.

반응형

'Spring > Spring Security' 카테고리의 다른 글

스프링 시큐리티의 권한(Authorization)  (0) 2021.10.15
권한체크 오류처리  (0) 2021.10.15
RememberMe  (0) 2021.10.12
UserDetailsService  (0) 2021.10.11
BaiscToken으로 웹/모바일 로그인 인증 구현하기  (0) 2021.10.11