001/* 002 * Copyright 2002-2019 the original author or authors. 003 * 004 * Licensed under the Apache License, Version 2.0 (the "License"); 005 * you may not use this file except in compliance with the License. 006 * You may obtain a copy of the License at 007 * 008 * https://www.apache.org/licenses/LICENSE-2.0 009 * 010 * Unless required by applicable law or agreed to in writing, software 011 * distributed under the License is distributed on an "AS IS" BASIS, 012 * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 013 * See the License for the specific language governing permissions and 014 * limitations under the License. 015 */ 016 017package org.springframework.messaging.simp.user; 018 019import java.security.Principal; 020import java.util.Collections; 021import java.util.HashSet; 022import java.util.Set; 023 024import org.apache.commons.logging.Log; 025 026import org.springframework.lang.Nullable; 027import org.springframework.messaging.Message; 028import org.springframework.messaging.MessageHeaders; 029import org.springframework.messaging.simp.SimpLogging; 030import org.springframework.messaging.simp.SimpMessageHeaderAccessor; 031import org.springframework.messaging.simp.SimpMessageType; 032import org.springframework.util.Assert; 033import org.springframework.util.PathMatcher; 034import org.springframework.util.StringUtils; 035 036/** 037 * A default implementation of {@code UserDestinationResolver} that relies 038 * on a {@link SimpUserRegistry} to find active sessions for a user. 039 * 040 * <p>When a user attempts to subscribe, e.g. to "/user/queue/position-updates", 041 * the "/user" prefix is removed and a unique suffix added based on the session 042 * id, e.g. "/queue/position-updates-useri9oqdfzo" to ensure different users can 043 * subscribe to the same logical destination without colliding. 044 * 045 * <p>When sending to a user, e.g. "/user/{username}/queue/position-updates", the 046 * "/user/{username}" prefix is removed and a suffix based on active session id's 047 * is added, e.g. "/queue/position-updates-useri9oqdfzo". 048 * 049 * @author Rossen Stoyanchev 050 * @author Brian Clozel 051 * @since 4.0 052 */ 053public class DefaultUserDestinationResolver implements UserDestinationResolver { 054 055 private static final Log logger = SimpLogging.forLogName(DefaultUserDestinationResolver.class); 056 057 058 private final SimpUserRegistry userRegistry; 059 060 private String prefix = "/user/"; 061 062 private boolean removeLeadingSlash = false; 063 064 065 /** 066 * Create an instance that will access user session id information through 067 * the provided registry. 068 * @param userRegistry the registry, never {@code null} 069 */ 070 public DefaultUserDestinationResolver(SimpUserRegistry userRegistry) { 071 Assert.notNull(userRegistry, "SimpUserRegistry must not be null"); 072 this.userRegistry = userRegistry; 073 } 074 075 076 /** 077 * Return the configured {@link SimpUserRegistry}. 078 */ 079 public SimpUserRegistry getSimpUserRegistry() { 080 return this.userRegistry; 081 } 082 083 /** 084 * The prefix used to identify user destinations. Any destinations that do not 085 * start with the given prefix are not be resolved. 086 * <p>The default prefix is "/user/". 087 * @param prefix the prefix to use 088 */ 089 public void setUserDestinationPrefix(String prefix) { 090 Assert.hasText(prefix, "Prefix must not be empty"); 091 this.prefix = (prefix.endsWith("/") ? prefix : prefix + "/"); 092 } 093 094 /** 095 * Return the configured prefix for user destinations. 096 */ 097 public String getDestinationPrefix() { 098 return this.prefix; 099 } 100 101 /** 102 * Use this property to indicate whether the leading slash from translated 103 * user destinations should be removed or not. This depends on the 104 * destination prefixes the message broker is configured with. 105 * <p>By default this is set to {@code false}, i.e. 106 * "do not change the target destination", although 107 * {@link org.springframework.messaging.simp.config.AbstractMessageBrokerConfiguration 108 * AbstractMessageBrokerConfiguration} may change that to {@code true} 109 * if the configured destinations do not have a leading slash. 110 * @param remove whether to remove the leading slash 111 * @since 4.3.14 112 */ 113 public void setRemoveLeadingSlash(boolean remove) { 114 this.removeLeadingSlash = remove; 115 } 116 117 /** 118 * Whether to remove the leading slash from target destinations. 119 * @since 4.3.14 120 */ 121 public boolean isRemoveLeadingSlash() { 122 return this.removeLeadingSlash; 123 } 124 125 /** 126 * Provide the {@code PathMatcher} in use for working with destinations 127 * which in turn helps to determine whether the leading slash should be 128 * kept in actual destinations after removing the 129 * {@link #setUserDestinationPrefix userDestinationPrefix}. 130 * <p>By default actual destinations have a leading slash, e.g. 131 * {@code /queue/position-updates} which makes sense with brokers that 132 * support destinations with slash as separator. When a {@code PathMatcher} 133 * is provided that supports an alternative separator, then resulting 134 * destinations won't have a leading slash, e.g. {@code 135 * jms.queue.position-updates}. 136 * @param pathMatcher the PathMatcher used to work with destinations 137 * @since 4.3 138 * @deprecated as of 4.3.14 this property is no longer used and is replaced 139 * by {@link #setRemoveLeadingSlash(boolean)} that indicates more explicitly 140 * whether to keep the leading slash which may or may not be the case 141 * regardless of how the {@code PathMatcher} is configured. 142 */ 143 @Deprecated 144 public void setPathMatcher(@Nullable PathMatcher pathMatcher) { 145 // Do nothing 146 } 147 148 149 @Override 150 @Nullable 151 public UserDestinationResult resolveDestination(Message<?> message) { 152 ParseResult parseResult = parse(message); 153 if (parseResult == null) { 154 return null; 155 } 156 String user = parseResult.getUser(); 157 String sourceDestination = parseResult.getSourceDestination(); 158 Set<String> targetSet = new HashSet<>(); 159 for (String sessionId : parseResult.getSessionIds()) { 160 String actualDestination = parseResult.getActualDestination(); 161 String targetDestination = getTargetDestination( 162 sourceDestination, actualDestination, sessionId, user); 163 if (targetDestination != null) { 164 targetSet.add(targetDestination); 165 } 166 } 167 String subscribeDestination = parseResult.getSubscribeDestination(); 168 return new UserDestinationResult(sourceDestination, targetSet, subscribeDestination, user); 169 } 170 171 @Nullable 172 private ParseResult parse(Message<?> message) { 173 MessageHeaders headers = message.getHeaders(); 174 String sourceDestination = SimpMessageHeaderAccessor.getDestination(headers); 175 if (sourceDestination == null || !checkDestination(sourceDestination, this.prefix)) { 176 return null; 177 } 178 SimpMessageType messageType = SimpMessageHeaderAccessor.getMessageType(headers); 179 if (messageType != null) { 180 switch (messageType) { 181 case SUBSCRIBE: 182 case UNSUBSCRIBE: 183 return parseSubscriptionMessage(message, sourceDestination); 184 case MESSAGE: 185 return parseMessage(headers, sourceDestination); 186 } 187 } 188 return null; 189 } 190 191 @Nullable 192 private ParseResult parseSubscriptionMessage(Message<?> message, String sourceDestination) { 193 MessageHeaders headers = message.getHeaders(); 194 String sessionId = SimpMessageHeaderAccessor.getSessionId(headers); 195 if (sessionId == null) { 196 logger.error("No session id. Ignoring " + message); 197 return null; 198 } 199 int prefixEnd = this.prefix.length() - 1; 200 String actualDestination = sourceDestination.substring(prefixEnd); 201 if (isRemoveLeadingSlash()) { 202 actualDestination = actualDestination.substring(1); 203 } 204 Principal principal = SimpMessageHeaderAccessor.getUser(headers); 205 String user = (principal != null ? principal.getName() : null); 206 Assert.isTrue(user == null || !user.contains("%2F"), "Invalid sequence \"%2F\" in user name: " + user); 207 Set<String> sessionIds = Collections.singleton(sessionId); 208 return new ParseResult(sourceDestination, actualDestination, sourceDestination, sessionIds, user); 209 } 210 211 private ParseResult parseMessage(MessageHeaders headers, String sourceDest) { 212 int prefixEnd = this.prefix.length(); 213 int userEnd = sourceDest.indexOf('/', prefixEnd); 214 Assert.isTrue(userEnd > 0, "Expected destination pattern \"/user/{userId}/**\""); 215 String actualDest = sourceDest.substring(userEnd); 216 String subscribeDest = this.prefix.substring(0, prefixEnd - 1) + actualDest; 217 String userName = sourceDest.substring(prefixEnd, userEnd); 218 userName = StringUtils.replace(userName, "%2F", "/"); 219 220 String sessionId = SimpMessageHeaderAccessor.getSessionId(headers); 221 Set<String> sessionIds; 222 if (userName.equals(sessionId)) { 223 userName = null; 224 sessionIds = Collections.singleton(sessionId); 225 } 226 else { 227 sessionIds = getSessionIdsByUser(userName, sessionId); 228 } 229 230 if (isRemoveLeadingSlash()) { 231 actualDest = actualDest.substring(1); 232 } 233 return new ParseResult(sourceDest, actualDest, subscribeDest, sessionIds, userName); 234 } 235 236 private Set<String> getSessionIdsByUser(String userName, @Nullable String sessionId) { 237 Set<String> sessionIds; 238 SimpUser user = this.userRegistry.getUser(userName); 239 if (user != null) { 240 if (sessionId != null && user.getSession(sessionId) != null) { 241 sessionIds = Collections.singleton(sessionId); 242 } 243 else { 244 Set<SimpSession> sessions = user.getSessions(); 245 sessionIds = new HashSet<>(sessions.size()); 246 for (SimpSession session : sessions) { 247 sessionIds.add(session.getId()); 248 } 249 } 250 } 251 else { 252 sessionIds = Collections.emptySet(); 253 } 254 return sessionIds; 255 } 256 257 protected boolean checkDestination(String destination, String requiredPrefix) { 258 return destination.startsWith(requiredPrefix); 259 } 260 261 /** 262 * This method determines how to translate the source "user" destination to an 263 * actual target destination for the given active user session. 264 * @param sourceDestination the source destination from the input message. 265 * @param actualDestination a subset of the destination without any user prefix. 266 * @param sessionId the id of an active user session, never {@code null}. 267 * @param user the target user, possibly {@code null}, e.g if not authenticated. 268 * @return a target destination, or {@code null} if none 269 */ 270 @SuppressWarnings("unused") 271 @Nullable 272 protected String getTargetDestination(String sourceDestination, String actualDestination, 273 String sessionId, @Nullable String user) { 274 275 return actualDestination + "-user" + sessionId; 276 } 277 278 @Override 279 public String toString() { 280 return "DefaultUserDestinationResolver[prefix=" + this.prefix + "]"; 281 } 282 283 284 /** 285 * A temporary placeholder for a parsed source "user" destination. 286 */ 287 private static class ParseResult { 288 289 private final String sourceDestination; 290 291 private final String actualDestination; 292 293 private final String subscribeDestination; 294 295 private final Set<String> sessionIds; 296 297 @Nullable 298 private final String user; 299 300 public ParseResult(String sourceDest, String actualDest, String subscribeDest, 301 Set<String> sessionIds, @Nullable String user) { 302 303 this.sourceDestination = sourceDest; 304 this.actualDestination = actualDest; 305 this.subscribeDestination = subscribeDest; 306 this.sessionIds = sessionIds; 307 this.user = user; 308 } 309 310 public String getSourceDestination() { 311 return this.sourceDestination; 312 } 313 314 public String getActualDestination() { 315 return this.actualDestination; 316 } 317 318 public String getSubscribeDestination() { 319 return this.subscribeDestination; 320 } 321 322 public Set<String> getSessionIds() { 323 return this.sessionIds; 324 } 325 326 @Nullable 327 public String getUser() { 328 return this.user; 329 } 330 } 331 332}