View Javadoc
1   /*
2    * This file is part of ***  M y C o R e  ***
3    * See http://www.mycore.de/ for details.
4    *
5    * MyCoRe is free software: you can redistribute it and/or modify
6    * it under the terms of the GNU General Public License as published by
7    * the Free Software Foundation, either version 3 of the License, or
8    * (at your option) any later version.
9    *
10   * MyCoRe is distributed in the hope that it will be useful,
11   * but WITHOUT ANY WARRANTY; without even the implied warranty of
12   * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
13   * GNU General Public License for more details.
14   *
15   * You should have received a copy of the GNU General Public License
16   * along with MyCoRe.  If not, see <http://www.gnu.org/licenses/>.
17   */
18  
19  package org.mycore.frontend.support;
20  
21  import java.net.URI;
22  import java.net.URISyntaxException;
23  import java.nio.charset.StandardCharsets;
24  import java.security.MessageDigest;
25  import java.security.NoSuchAlgorithmException;
26  import java.util.Arrays;
27  import java.util.Base64;
28  import java.util.Objects;
29  import java.util.stream.Collectors;
30  import java.util.stream.Stream;
31  
32  /**
33   * An implementation of SecureToken V2 used by "Wowza Streaming Engine".
34   * <p>
35   * A description of the algorithm:
36   * </p>
37   * <ol>
38   * <li>A string is constructed by combining <code>contentPath</code>,'?' and all <strong>alphabetically sorted</strong>
39   * parameters consisting of <code>ipAddress</code>, <code>sharedSecret</code> and any <code>queryParameters</code></li>
40   * <li>Generate an <code>SHA-256</code> hash of that string builded in step 1 and {@link StandardCharsets#UTF_8}.</li>
41   * <li>Generate a {@link Base64} encoded string of the digest of step 2.</li>
42   * <li>replace <code>'+'</code> by <code>'-'</code> and <code>'/'</code> by <code>'_'</code> to make it a safe parameter
43   * value.</li>
44   * </ol>
45   *
46   * @author Thomas Scheffler (yagee)
47   * @see <a href="https://mycore.atlassian.net/browse/MCR-1058">JIRA Ticket MCR-1058</a>
48   */
49  public class MCRSecureTokenV2 {
50  
51      private String contentPath, ipAddress, sharedSecret, hash;
52  
53      private String[] queryParameters;
54  
55      public MCRSecureTokenV2(String contentPath, String ipAddress, String sharedSecret, String... queryParameters) {
56          this.contentPath = Objects.requireNonNull(contentPath, "'contentPath' may not be null");
57          this.ipAddress = Objects.requireNonNull(ipAddress, "'ipAddress' may not be null");
58          this.sharedSecret = Objects.requireNonNull(sharedSecret, "'sharedSecret' may not be null");
59          this.queryParameters = queryParameters;
60          try {
61              this.contentPath = new URI(null, null, this.contentPath, null).getRawPath();
62          } catch (URISyntaxException e) {
63              throw new RuntimeException(e);
64          }
65          buildHash();
66      }
67  
68      private void buildHash() {
69          String forHashing = Stream.concat(Stream.of(ipAddress, sharedSecret),
70              Arrays.stream(queryParameters).filter(Objects::nonNull)) //case of HttpServletRequest.getQueryString()==null
71              .sorted()
72              .collect(Collectors.joining("&", contentPath + "?", ""));
73          MessageDigest digest;
74          try {
75              digest = MessageDigest.getInstance("SHA-256");
76          } catch (NoSuchAlgorithmException e) {
77              throw new RuntimeException(e);//should never happen for 'SHA-256'
78          }
79          digest.update(URI.create(forHashing).toASCIIString().getBytes(StandardCharsets.US_ASCII));
80          byte[] sha256 = digest.digest();
81          hash = Base64.getEncoder()
82              .encodeToString(sha256)
83              .chars()
84              .map(x -> {
85                  switch (x) {
86                  case '+':
87                      return '-';
88                  case '/':
89                      return '_';
90                  default:
91                      return x;
92                  }
93              })
94              .collect(StringBuilder::new, StringBuilder::appendCodePoint, StringBuilder::append)
95              .toString();
96      }
97  
98      public String getHash() {
99          return hash;
100     }
101 
102     /**
103      * Same as calling {@link #toURI(String, String, String)} with <code>suffix=""</code>.
104      */
105     public URI toURI(String baseURL, String hashParameterName) throws URISyntaxException {
106         return toURI(baseURL, "", hashParameterName);
107     }
108 
109     /**
110      * Constructs an URL by using all information from the
111      * {@link MCRSecureTokenV2#MCRSecureTokenV2(String, String, String, String...) constructor} except
112      * <code>ipAddress</code> and <code>sharedSecret</code> and the supplied parameters.
113      *
114      * @param baseURL
115      *            a valid and absolute base URL
116      * @param suffix
117      *            is appended to the <code>contentPath</code>
118      * @param hashParameterName
119      *            the name of the query parameter that holds the hash value
120      * @return an absolute URL consisting of all elements as stated above and <code>queryParameters</code> in the
121      *         <strong>given order</strong> appended by the hash parameter and the hash value from {@link #getHash()}.
122      * @throws URISyntaxException  if baseURL is not a valid URI
123      */
124     public URI toURI(String baseURL, String suffix, String hashParameterName) throws URISyntaxException {
125         Objects.requireNonNull(suffix, "'suffix' may not be null");
126         Objects.requireNonNull(hashParameterName, "'hashParameterName' may not be null");
127         if (hashParameterName.isEmpty()) {
128             throw new IllegalArgumentException("'hashParameterName' may not be empty");
129         }
130         URI context = new URI(baseURL);
131         return context.resolve(Stream
132             .concat(Arrays.stream(queryParameters).filter(Objects::nonNull), Stream.of(hashParameterName + "=" + hash))
133             .collect(Collectors.joining("&", baseURL + contentPath + suffix + "?", "")));
134     }
135 
136     @Override
137     public int hashCode() {
138         return getHash().hashCode();
139     }
140 
141     @Override
142     public boolean equals(Object obj) {
143         if (this == obj) {
144             return true;
145         }
146         if (obj == null) {
147             return false;
148         }
149         if (getClass() != obj.getClass()) {
150             return false;
151         }
152         MCRSecureTokenV2 other = (MCRSecureTokenV2) obj;
153         if (!hash.equals(other.hash)) {
154             return false;
155         }
156         if (!contentPath.equals(other.contentPath)) {
157             return false;
158         }
159         if (!ipAddress.equals(other.ipAddress)) {
160             return false;
161         }
162         if (!sharedSecret.equals(other.sharedSecret)) {
163             return false;
164         }
165         return Arrays.equals(queryParameters, other.queryParameters);
166     }
167 
168 }