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