You can not select more than 25 topics Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.

JGitClientSession.java 20KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678679
  1. /*
  2. * Copyright (C) 2018, 2019 Thomas Wolf <thomas.wolf@paranor.ch> and others
  3. *
  4. * This program and the accompanying materials are made available under the
  5. * terms of the Eclipse Distribution License v. 1.0 which is available at
  6. * https://www.eclipse.org/org/documents/edl-v10.php.
  7. *
  8. * SPDX-License-Identifier: BSD-3-Clause
  9. */
  10. package org.eclipse.jgit.internal.transport.sshd;
  11. import static java.text.MessageFormat.format;
  12. import java.io.IOException;
  13. import java.io.StreamCorruptedException;
  14. import java.net.SocketAddress;
  15. import java.nio.charset.StandardCharsets;
  16. import java.security.GeneralSecurityException;
  17. import java.security.PublicKey;
  18. import java.util.ArrayList;
  19. import java.util.Collection;
  20. import java.util.Collections;
  21. import java.util.Iterator;
  22. import java.util.LinkedHashSet;
  23. import java.util.List;
  24. import java.util.Map;
  25. import java.util.Objects;
  26. import java.util.Set;
  27. import org.apache.sshd.client.ClientFactoryManager;
  28. import org.apache.sshd.client.config.hosts.HostConfigEntry;
  29. import org.apache.sshd.client.future.AuthFuture;
  30. import org.apache.sshd.client.keyverifier.ServerKeyVerifier;
  31. import org.apache.sshd.client.session.ClientSessionImpl;
  32. import org.apache.sshd.client.session.ClientUserAuthService;
  33. import org.apache.sshd.common.AttributeRepository;
  34. import org.apache.sshd.common.FactoryManager;
  35. import org.apache.sshd.common.PropertyResolver;
  36. import org.apache.sshd.common.PropertyResolverUtils;
  37. import org.apache.sshd.common.SshException;
  38. import org.apache.sshd.common.config.keys.KeyUtils;
  39. import org.apache.sshd.common.io.IoSession;
  40. import org.apache.sshd.common.io.IoWriteFuture;
  41. import org.apache.sshd.common.kex.KexState;
  42. import org.apache.sshd.common.util.Readable;
  43. import org.apache.sshd.common.util.buffer.Buffer;
  44. import org.eclipse.jgit.errors.InvalidPatternException;
  45. import org.eclipse.jgit.fnmatch.FileNameMatcher;
  46. import org.eclipse.jgit.internal.transport.sshd.proxy.StatefulProxyConnector;
  47. import org.eclipse.jgit.transport.CredentialsProvider;
  48. import org.eclipse.jgit.transport.SshConstants;
  49. /**
  50. * A {@link org.apache.sshd.client.session.ClientSession ClientSession} that can
  51. * be associated with the {@link HostConfigEntry} the session was created for.
  52. * The {@link JGitSshClient} creates such sessions and sets this association.
  53. * <p>
  54. * Also provides for associating a JGit {@link CredentialsProvider} with a
  55. * session.
  56. * </p>
  57. */
  58. public class JGitClientSession extends ClientSessionImpl {
  59. /**
  60. * Default setting for the maximum number of bytes to read in the initial
  61. * protocol version exchange. 64kb is what OpenSSH < 8.0 read; OpenSSH 8.0
  62. * changed it to 8Mb, but that seems excessive for the purpose stated in RFC
  63. * 4253. The Apache MINA sshd default in
  64. * {@link FactoryManager#DEFAULT_MAX_IDENTIFICATION_SIZE} is 16kb.
  65. */
  66. private static final int DEFAULT_MAX_IDENTIFICATION_SIZE = 64 * 1024;
  67. private HostConfigEntry hostConfig;
  68. private CredentialsProvider credentialsProvider;
  69. private volatile StatefulProxyConnector proxyHandler;
  70. /**
  71. * Work-around for bug 565394 / SSHD-1050; remove when using sshd 2.6.0.
  72. */
  73. private volatile AuthFuture authFuture;
  74. /** Records exceptions before there is an authFuture. */
  75. private List<Throwable> earlyErrors = new ArrayList<>();
  76. /** Guards setting an earlyError and the authFuture together. */
  77. private final Object errorLock = new Object();
  78. /**
  79. * @param manager
  80. * @param session
  81. * @throws Exception
  82. */
  83. public JGitClientSession(ClientFactoryManager manager, IoSession session)
  84. throws Exception {
  85. super(manager, session);
  86. }
  87. // BEGIN Work-around for bug 565394 / SSHD-1050
  88. // Remove when using sshd 2.6.0.
  89. @Override
  90. public AuthFuture auth() throws IOException {
  91. if (getUsername() == null) {
  92. throw new IllegalStateException(
  93. SshdText.get().sessionWithoutUsername);
  94. }
  95. ClientUserAuthService authService = getUserAuthService();
  96. String serviceName = nextServiceName();
  97. List<Throwable> errors = null;
  98. AuthFuture future;
  99. // Guard both getting early errors and setting authFuture
  100. synchronized (errorLock) {
  101. future = authService.auth(serviceName);
  102. if (future == null) {
  103. // Internal error; no translation.
  104. throw new IllegalStateException(
  105. "No auth future generated by service '" //$NON-NLS-1$
  106. + serviceName + '\'');
  107. }
  108. errors = earlyErrors;
  109. earlyErrors = null;
  110. authFuture = future;
  111. }
  112. if (errors != null && !errors.isEmpty()) {
  113. Iterator<Throwable> iter = errors.iterator();
  114. Throwable first = iter.next();
  115. iter.forEachRemaining(t -> {
  116. if (t != first && t != null) {
  117. first.addSuppressed(t);
  118. }
  119. });
  120. // Mark the future as having had an exception; just to be on the
  121. // safe side. Actually, there shouldn't be anyone waiting on this
  122. // future yet.
  123. future.setException(first);
  124. if (log.isDebugEnabled()) {
  125. log.debug("auth({}) early exception type={}: {}", //$NON-NLS-1$
  126. this, first.getClass().getSimpleName(),
  127. first.getMessage());
  128. }
  129. if (first instanceof SshException) {
  130. throw new SshException(
  131. ((SshException) first).getDisconnectCode(),
  132. first.getMessage(), first);
  133. }
  134. throw new IOException(first.getMessage(), first);
  135. }
  136. return future;
  137. }
  138. @Override
  139. protected void signalAuthFailure(AuthFuture future, Throwable t) {
  140. signalAuthFailure(t);
  141. }
  142. private void signalAuthFailure(Throwable t) {
  143. AuthFuture future = authFuture;
  144. if (future == null) {
  145. synchronized (errorLock) {
  146. if (earlyErrors != null) {
  147. earlyErrors.add(t);
  148. }
  149. future = authFuture;
  150. }
  151. }
  152. if (future != null) {
  153. future.setException(t);
  154. }
  155. if (log.isDebugEnabled()) {
  156. boolean signalled = future != null && t == future.getException();
  157. log.debug("signalAuthFailure({}) type={}, signalled={}: {}", this, //$NON-NLS-1$
  158. t.getClass().getSimpleName(), Boolean.valueOf(signalled),
  159. t.getMessage());
  160. }
  161. }
  162. @Override
  163. public void exceptionCaught(Throwable t) {
  164. signalAuthFailure(t);
  165. super.exceptionCaught(t);
  166. }
  167. @Override
  168. protected void preClose() {
  169. signalAuthFailure(
  170. new SshException(SshdText.get().authenticationOnClosedSession));
  171. super.preClose();
  172. }
  173. @Override
  174. protected void handleDisconnect(int code, String msg, String lang,
  175. Buffer buffer) throws Exception {
  176. signalAuthFailure(new SshException(code, msg));
  177. super.handleDisconnect(code, msg, lang, buffer);
  178. }
  179. @Override
  180. protected <C extends Collection<ClientSessionEvent>> C updateCurrentSessionState(
  181. C newState) {
  182. if (closeFuture.isClosed()) {
  183. newState.add(ClientSessionEvent.CLOSED);
  184. }
  185. if (isAuthenticated()) { // authFuture.isSuccess()
  186. newState.add(ClientSessionEvent.AUTHED);
  187. }
  188. if (KexState.DONE.equals(getKexState())) {
  189. AuthFuture future = authFuture;
  190. if (future == null || future.isFailure()) {
  191. newState.add(ClientSessionEvent.WAIT_AUTH);
  192. }
  193. }
  194. return newState;
  195. }
  196. // END Work-around for bug 565394 / SSHD-1050
  197. /**
  198. * Retrieves the {@link HostConfigEntry} this session was created for.
  199. *
  200. * @return the {@link HostConfigEntry}, or {@code null} if none set
  201. */
  202. public HostConfigEntry getHostConfigEntry() {
  203. return hostConfig;
  204. }
  205. /**
  206. * Sets the {@link HostConfigEntry} this session was created for.
  207. *
  208. * @param hostConfig
  209. * the {@link HostConfigEntry}
  210. */
  211. public void setHostConfigEntry(HostConfigEntry hostConfig) {
  212. this.hostConfig = hostConfig;
  213. }
  214. /**
  215. * Sets the {@link CredentialsProvider} for this session.
  216. *
  217. * @param provider
  218. * to set
  219. */
  220. public void setCredentialsProvider(CredentialsProvider provider) {
  221. credentialsProvider = provider;
  222. }
  223. /**
  224. * Retrieves the {@link CredentialsProvider} set for this session.
  225. *
  226. * @return the provider, or {@code null} if none is set.
  227. */
  228. public CredentialsProvider getCredentialsProvider() {
  229. return credentialsProvider;
  230. }
  231. /**
  232. * Sets a {@link StatefulProxyConnector} to handle proxy connection
  233. * protocols.
  234. *
  235. * @param handler
  236. * to set
  237. */
  238. public void setProxyHandler(StatefulProxyConnector handler) {
  239. proxyHandler = handler;
  240. }
  241. @Override
  242. protected IoWriteFuture sendIdentification(String ident)
  243. throws IOException {
  244. StatefulProxyConnector proxy = proxyHandler;
  245. if (proxy != null) {
  246. try {
  247. // We must not block here; the framework starts reading messages
  248. // from the peer only once the initial sendKexInit() following
  249. // this call to sendIdentification() has returned!
  250. proxy.runWhenDone(() -> {
  251. JGitClientSession.super.sendIdentification(ident);
  252. return null;
  253. });
  254. // Called only from the ClientSessionImpl constructor, where the
  255. // return value is ignored.
  256. return null;
  257. } catch (IOException e) {
  258. throw e;
  259. } catch (Exception other) {
  260. throw new IOException(other.getLocalizedMessage(), other);
  261. }
  262. }
  263. return super.sendIdentification(ident);
  264. }
  265. @Override
  266. protected byte[] sendKexInit()
  267. throws IOException, GeneralSecurityException {
  268. StatefulProxyConnector proxy = proxyHandler;
  269. if (proxy != null) {
  270. try {
  271. // We must not block here; the framework starts reading messages
  272. // from the peer only once the initial sendKexInit() has
  273. // returned!
  274. proxy.runWhenDone(() -> {
  275. JGitClientSession.super.sendKexInit();
  276. return null;
  277. });
  278. // This is called only from the ClientSessionImpl
  279. // constructor, where the return value is ignored.
  280. return null;
  281. } catch (IOException | GeneralSecurityException e) {
  282. throw e;
  283. } catch (Exception other) {
  284. throw new IOException(other.getLocalizedMessage(), other);
  285. }
  286. }
  287. return super.sendKexInit();
  288. }
  289. /**
  290. * {@inheritDoc}
  291. *
  292. * As long as we're still setting up the proxy connection, diverts messages
  293. * to the {@link StatefulProxyConnector}.
  294. */
  295. @Override
  296. public void messageReceived(Readable buffer) throws Exception {
  297. StatefulProxyConnector proxy = proxyHandler;
  298. if (proxy != null) {
  299. proxy.messageReceived(getIoSession(), buffer);
  300. } else {
  301. super.messageReceived(buffer);
  302. }
  303. }
  304. @Override
  305. protected void checkKeys() throws SshException {
  306. ServerKeyVerifier serverKeyVerifier = getServerKeyVerifier();
  307. // The super implementation always uses
  308. // getIoSession().getRemoteAddress(). In case of a proxy connection,
  309. // that would be the address of the proxy!
  310. SocketAddress remoteAddress = getConnectAddress();
  311. PublicKey serverKey = getKex().getServerKey();
  312. if (!serverKeyVerifier.verifyServerKey(this, remoteAddress,
  313. serverKey)) {
  314. throw new SshException(
  315. org.apache.sshd.common.SshConstants.SSH2_DISCONNECT_HOST_KEY_NOT_VERIFIABLE,
  316. SshdText.get().kexServerKeyInvalid);
  317. }
  318. }
  319. @Override
  320. protected String resolveAvailableSignaturesProposal(
  321. FactoryManager manager) {
  322. Set<String> defaultSignatures = new LinkedHashSet<>();
  323. defaultSignatures.addAll(getSignatureFactoriesNames());
  324. HostConfigEntry config = resolveAttribute(
  325. JGitSshClient.HOST_CONFIG_ENTRY);
  326. String hostKeyAlgorithms = config
  327. .getProperty(SshConstants.HOST_KEY_ALGORITHMS);
  328. if (hostKeyAlgorithms != null && !hostKeyAlgorithms.isEmpty()) {
  329. char first = hostKeyAlgorithms.charAt(0);
  330. switch (first) {
  331. case '+':
  332. // Additions make not much sense -- it's either in
  333. // defaultSignatures already, or we have no implementation for
  334. // it. No point in proposing it.
  335. return String.join(",", defaultSignatures); //$NON-NLS-1$
  336. case '-':
  337. // This takes wildcard patterns!
  338. removeFromList(defaultSignatures,
  339. SshConstants.HOST_KEY_ALGORITHMS,
  340. hostKeyAlgorithms.substring(1));
  341. if (defaultSignatures.isEmpty()) {
  342. // Too bad: user config error. Warn here, and then fail
  343. // later.
  344. log.warn(format(
  345. SshdText.get().configNoRemainingHostKeyAlgorithms,
  346. hostKeyAlgorithms));
  347. }
  348. return String.join(",", defaultSignatures); //$NON-NLS-1$
  349. default:
  350. // Default is overridden -- only accept the ones for which we do
  351. // have an implementation.
  352. List<String> newNames = filteredList(defaultSignatures,
  353. hostKeyAlgorithms);
  354. if (newNames.isEmpty()) {
  355. log.warn(format(
  356. SshdText.get().configNoKnownHostKeyAlgorithms,
  357. hostKeyAlgorithms));
  358. // Use the default instead.
  359. } else {
  360. return String.join(",", newNames); //$NON-NLS-1$
  361. }
  362. break;
  363. }
  364. }
  365. // No HostKeyAlgorithms; using default -- change order to put existing
  366. // keys first.
  367. ServerKeyVerifier verifier = getServerKeyVerifier();
  368. if (verifier instanceof ServerKeyLookup) {
  369. SocketAddress remoteAddress = resolvePeerAddress(
  370. resolveAttribute(JGitSshClient.ORIGINAL_REMOTE_ADDRESS));
  371. List<PublicKey> allKnownKeys = ((ServerKeyLookup) verifier)
  372. .lookup(this, remoteAddress);
  373. Set<String> reordered = new LinkedHashSet<>();
  374. for (PublicKey key : allKnownKeys) {
  375. if (key != null) {
  376. String keyType = KeyUtils.getKeyType(key);
  377. if (keyType != null) {
  378. reordered.add(keyType);
  379. }
  380. }
  381. }
  382. reordered.addAll(defaultSignatures);
  383. return String.join(",", reordered); //$NON-NLS-1$
  384. }
  385. return String.join(",", defaultSignatures); //$NON-NLS-1$
  386. }
  387. private void removeFromList(Set<String> current, String key,
  388. String patterns) {
  389. for (String toRemove : patterns.split("\\s*,\\s*")) { //$NON-NLS-1$
  390. if (toRemove.indexOf('*') < 0 && toRemove.indexOf('?') < 0) {
  391. current.remove(toRemove);
  392. continue;
  393. }
  394. try {
  395. FileNameMatcher matcher = new FileNameMatcher(toRemove, null);
  396. for (Iterator<String> i = current.iterator(); i.hasNext();) {
  397. matcher.reset();
  398. matcher.append(i.next());
  399. if (matcher.isMatch()) {
  400. i.remove();
  401. }
  402. }
  403. } catch (InvalidPatternException e) {
  404. log.warn(format(SshdText.get().configInvalidPattern, key,
  405. toRemove));
  406. }
  407. }
  408. }
  409. private List<String> filteredList(Set<String> known, String values) {
  410. List<String> newNames = new ArrayList<>();
  411. for (String newValue : values.split("\\s*,\\s*")) { //$NON-NLS-1$
  412. if (known.contains(newValue)) {
  413. newNames.add(newValue);
  414. }
  415. }
  416. return newNames;
  417. }
  418. /**
  419. * Reads the RFC 4253, section 4.2 protocol version identification. The
  420. * Apache MINA sshd default implementation checks for NUL bytes also in any
  421. * preceding lines, whereas RFC 4253 requires such a check only for the
  422. * actual identification string starting with "SSH-". Likewise, the 255
  423. * character limit exists only for the identification string, not for the
  424. * preceding lines. CR-LF handling is also relaxed.
  425. *
  426. * @param buffer
  427. * to read from
  428. * @param server
  429. * whether we're an SSH server (should always be {@code false})
  430. * @return the lines read, with the server identification line last, or
  431. * {@code null} if no identification line was found and more bytes
  432. * are needed
  433. * @throws StreamCorruptedException
  434. * if the identification is malformed
  435. * @see <a href="https://tools.ietf.org/html/rfc4253#section-4.2">RFC 4253,
  436. * section 4.2</a>
  437. */
  438. @Override
  439. protected List<String> doReadIdentification(Buffer buffer, boolean server)
  440. throws StreamCorruptedException {
  441. if (server) {
  442. // Should never happen. No translation; internal bug.
  443. throw new IllegalStateException(
  444. "doReadIdentification of client called with server=true"); //$NON-NLS-1$
  445. }
  446. int maxIdentSize = PropertyResolverUtils.getIntProperty(this,
  447. FactoryManager.MAX_IDENTIFICATION_SIZE,
  448. DEFAULT_MAX_IDENTIFICATION_SIZE);
  449. int current = buffer.rpos();
  450. int end = current + buffer.available();
  451. if (current >= end) {
  452. return null;
  453. }
  454. byte[] raw = buffer.array();
  455. List<String> ident = new ArrayList<>();
  456. int start = current;
  457. boolean hasNul = false;
  458. for (int i = current; i < end; i++) {
  459. switch (raw[i]) {
  460. case 0:
  461. hasNul = true;
  462. break;
  463. case '\n':
  464. int eol = 1;
  465. if (i > start && raw[i - 1] == '\r') {
  466. eol++;
  467. }
  468. String line = new String(raw, start, i + 1 - eol - start,
  469. StandardCharsets.UTF_8);
  470. start = i + 1;
  471. if (log.isDebugEnabled()) {
  472. log.debug(format("doReadIdentification({0}) line: ", this) + //$NON-NLS-1$
  473. escapeControls(line));
  474. }
  475. ident.add(line);
  476. if (line.startsWith("SSH-")) { //$NON-NLS-1$
  477. if (hasNul) {
  478. throw new StreamCorruptedException(
  479. format(SshdText.get().serverIdWithNul,
  480. escapeControls(line)));
  481. }
  482. if (line.length() + eol > 255) {
  483. throw new StreamCorruptedException(
  484. format(SshdText.get().serverIdTooLong,
  485. escapeControls(line)));
  486. }
  487. buffer.rpos(start);
  488. return ident;
  489. }
  490. // If this were a server, we could throw an exception here: a
  491. // client is not supposed to send any extra lines before its
  492. // identification string.
  493. hasNul = false;
  494. break;
  495. default:
  496. break;
  497. }
  498. if (i - current + 1 >= maxIdentSize) {
  499. String msg = format(SshdText.get().serverIdNotReceived,
  500. Integer.toString(maxIdentSize));
  501. if (log.isDebugEnabled()) {
  502. log.debug(msg);
  503. log.debug(buffer.toHex());
  504. }
  505. throw new StreamCorruptedException(msg);
  506. }
  507. }
  508. // Need more data
  509. return null;
  510. }
  511. private static String escapeControls(String s) {
  512. StringBuilder b = new StringBuilder();
  513. int l = s.length();
  514. for (int i = 0; i < l; i++) {
  515. char ch = s.charAt(i);
  516. if (Character.isISOControl(ch)) {
  517. b.append(ch <= 0xF ? "\\u000" : "\\u00") //$NON-NLS-1$ //$NON-NLS-2$
  518. .append(Integer.toHexString(ch));
  519. } else {
  520. b.append(ch);
  521. }
  522. }
  523. return b.toString();
  524. }
  525. @Override
  526. public <T> T getAttribute(AttributeKey<T> key) {
  527. T value = super.getAttribute(key);
  528. if (value == null) {
  529. IoSession ioSession = getIoSession();
  530. if (ioSession != null) {
  531. Object obj = ioSession.getAttribute(AttributeRepository.class);
  532. if (obj instanceof AttributeRepository) {
  533. AttributeRepository sessionAttributes = (AttributeRepository) obj;
  534. value = sessionAttributes.resolveAttribute(key);
  535. }
  536. }
  537. }
  538. return value;
  539. }
  540. @Override
  541. public PropertyResolver getParentPropertyResolver() {
  542. IoSession ioSession = getIoSession();
  543. if (ioSession != null) {
  544. Object obj = ioSession.getAttribute(AttributeRepository.class);
  545. if (obj instanceof PropertyResolver) {
  546. return (PropertyResolver) obj;
  547. }
  548. }
  549. return super.getParentPropertyResolver();
  550. }
  551. /**
  552. * An {@link AttributeRepository} that chains together two other attribute
  553. * sources in a hierarchy.
  554. */
  555. public static class ChainingAttributes implements AttributeRepository {
  556. private final AttributeRepository delegate;
  557. private final AttributeRepository parent;
  558. /**
  559. * Create a new {@link ChainingAttributes} attribute source.
  560. *
  561. * @param self
  562. * to search for attributes first
  563. * @param parent
  564. * to search for attributes if not found in {@code self}
  565. */
  566. public ChainingAttributes(AttributeRepository self,
  567. AttributeRepository parent) {
  568. this.delegate = self;
  569. this.parent = parent;
  570. }
  571. @Override
  572. public int getAttributesCount() {
  573. return delegate.getAttributesCount();
  574. }
  575. @Override
  576. public <T> T getAttribute(AttributeKey<T> key) {
  577. return delegate.getAttribute(Objects.requireNonNull(key));
  578. }
  579. @Override
  580. public Collection<AttributeKey<?>> attributeKeys() {
  581. return delegate.attributeKeys();
  582. }
  583. @Override
  584. public <T> T resolveAttribute(AttributeKey<T> key) {
  585. T value = getAttribute(Objects.requireNonNull(key));
  586. if (value == null) {
  587. return parent.getAttribute(key);
  588. }
  589. return value;
  590. }
  591. }
  592. /**
  593. * A {@link ChainingAttributes} repository that doubles as a
  594. * {@link PropertyResolver}. The property map can be set via the attribute
  595. * key {@link SessionAttributes#PROPERTIES}.
  596. */
  597. public static class SessionAttributes extends ChainingAttributes
  598. implements PropertyResolver {
  599. /** Key for storing a map of properties in the attributes. */
  600. public static final AttributeKey<Map<String, Object>> PROPERTIES = new AttributeKey<>();
  601. private final PropertyResolver parentProperties;
  602. /**
  603. * Creates a new {@link SessionAttributes} attribute and property
  604. * source.
  605. *
  606. * @param self
  607. * to search for attributes first
  608. * @param parent
  609. * to search for attributes if not found in {@code self}
  610. * @param parentProperties
  611. * to search for properties if not found in {@code self}
  612. */
  613. public SessionAttributes(AttributeRepository self,
  614. AttributeRepository parent, PropertyResolver parentProperties) {
  615. super(self, parent);
  616. this.parentProperties = parentProperties;
  617. }
  618. @Override
  619. public PropertyResolver getParentPropertyResolver() {
  620. return parentProperties;
  621. }
  622. @Override
  623. public Map<String, Object> getProperties() {
  624. Map<String, Object> props = getAttribute(PROPERTIES);
  625. return props == null ? Collections.emptyMap() : props;
  626. }
  627. }
  628. }