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}