View Javadoc
1   /**
2    * Copyright (c) 2012-2017, s3auth.com
3    * All rights reserved.
4    *
5    * Redistribution and use in source and binary forms, with or without
6    * modification, are permitted provided that the following conditions
7    * are met: 1) Redistributions of source code must retain the above
8    * copyright notice, this list of conditions and the following
9    * disclaimer. 2) Redistributions in binary form must reproduce the above
10   * copyright notice, this list of conditions and the following
11   * disclaimer in the documentation and/or other materials provided
12   * with the distribution. 3) Neither the name of the s3auth.com nor
13   * the names of its contributors may be used to endorse or promote
14   * products derived from this software without specific prior written
15   * permission.
16   *
17   * THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS
18   * "AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT
19   * NOT LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND
20   * FITNESS FOR A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL
21   * THE COPYRIGHT HOLDER OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT,
22   * INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES
23   * (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR
24   * SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION)
25   * HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT,
26   * STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE)
27   * ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED
28   * OF THE POSSIBILITY OF SUCH DAMAGE.
29   */
30  package com.s3auth.hosts;
31  
32  import com.jcabi.aspects.Cacheable;
33  import com.jcabi.aspects.Immutable;
34  import com.jcabi.aspects.LogExceptions;
35  import com.jcabi.aspects.Loggable;
36  import com.jcabi.log.Logger;
37  import java.io.ByteArrayOutputStream;
38  import java.io.IOException;
39  import java.net.URI;
40  import java.util.concurrent.ConcurrentHashMap;
41  import java.util.concurrent.ConcurrentMap;
42  import java.util.concurrent.TimeUnit;
43  import java.util.regex.Matcher;
44  import java.util.regex.Pattern;
45  import javax.validation.constraints.NotNull;
46  import lombok.EqualsAndHashCode;
47  import org.apache.commons.codec.binary.Base64;
48  import org.apache.commons.codec.digest.Crypt;
49  import org.apache.commons.codec.digest.DigestUtils;
50  import org.apache.commons.codec.digest.Md5Crypt;
51  import org.apache.commons.io.Charsets;
52  
53  /**
54   * Htpasswd file abstraction.
55   *
56   * @author Yegor Bugayenko (yegor@tpc2.com)
57   * @version $Id: e4011a47559afb831cefde88650d520269cd0a8d $
58   * @since 0.0.1
59   * @checkstyle ClassDataAbstractionCoupling (500 lines)
60   */
61  @Immutable
62  @Loggable(Loggable.DEBUG)
63  @EqualsAndHashCode(of = "host")
64  @SuppressWarnings("PMD.UnusedPrivateField")
65  final class Htpasswd {
66  
67      /**
68       * Lifetime of HTPASSWD in memory, in minutes.
69       */
70      private static final int LIFETIME = 5;
71  
72      /**
73       * All known algorithms.
74       * @see <a href="http://httpd.apache.org/docs/2.2/misc/password_encryptions.html">Algorithms supported by Apache</a>
75       */
76      private static final Htpasswd.Algorithm[] ALGORITHMS = {
77          new Htpasswd.Md5(),
78          new Htpasswd.Sha(),
79          new Htpasswd.UnixCrypt(),
80          new Htpasswd.PlainText(),
81      };
82  
83      /**
84       * The host we're working with.
85       */
86      private final transient Host host;
87  
88      /**
89       * Public ctor.
90       * @param hst The host to work with
91       */
92      Htpasswd(@NotNull final Host hst) {
93          this.host = hst;
94      }
95  
96      @Override
97      public String toString() {
98          return Logger.format(
99              ".htpasswd(%d user(s), reloaded every %d min)",
100             this.fetch().size(),
101             Htpasswd.LIFETIME
102         );
103     }
104 
105     /**
106      * Can this user login in with this credentials?
107      * @param user User name
108      * @param password Password
109      * @return Yes or no
110      * @throws IOException If some error inside
111      */
112     @LogExceptions
113     public boolean authorized(@NotNull final String user,
114         @NotNull final String password) throws IOException {
115         final ConcurrentMap<String, String> users = this.fetch();
116         return users.containsKey(user)
117             && Htpasswd.matches(users.get(user), password);
118     }
119 
120     /**
121      * Get map of users and passwords from the host.
122      * @return Map of users
123      */
124     @Cacheable(lifetime = Htpasswd.LIFETIME, unit = TimeUnit.MINUTES)
125     private ConcurrentMap<String, String> fetch() {
126         final ConcurrentMap<String, String> map =
127             new ConcurrentHashMap<String, String>(0);
128         final String[] lines = this.content().split("\n");
129         for (final String line : lines) {
130             if (line.isEmpty()) {
131                 continue;
132             }
133             final String[] parts = line.trim().split(":", 2);
134             if (parts.length != 2) {
135                 continue;
136             }
137             map.put(parts[0].trim(), parts[1].trim());
138         }
139         return map;
140     }
141 
142     /**
143      * Fetch the .htpasswd file, or returns empty string if it's absent.
144      * @return Content of .htpasswd file, or empty
145      */
146     private String content() {
147         String content;
148         try {
149             final Resource res = this.host.fetch(
150                 URI.create("/.htpasswd"),
151                 Range.ENTIRE,
152                 Version.LATEST
153             );
154             final ByteArrayOutputStream baos = new ByteArrayOutputStream();
155             res.writeTo(baos);
156             content = baos.toString(Charsets.UTF_8.name()).trim();
157         } catch (final IOException ex) {
158             Logger.warn(
159                 this,
160                 "#content(): failed to fetch .htpasswd from %s: %s",
161                 this.host, ex.getMessage()
162             );
163             content = "";
164         }
165         return content;
166     }
167 
168     /**
169      * Hash matches the password?
170      * @param hash The hash to match
171      * @param password Password
172      * @return TRUE if they match
173      * @throws IOException If some error inside
174      */
175     private static boolean matches(final String hash, final String password)
176         throws IOException {
177         boolean matches = false;
178         for (final Htpasswd.Algorithm algo : Htpasswd.ALGORITHMS) {
179             if (algo.matches(hash, password)) {
180                 matches = true;
181                 break;
182             }
183         }
184         return matches;
185     }
186 
187     /**
188      * Algorithm.
189      */
190     private interface Algorithm {
191         /**
192          * Do they match?
193          * @param hash The hash
194          * @param password The password
195          * @return TRUE if they match
196          * @throws IOException If some error inside
197          */
198         boolean matches(String hash, String password) throws IOException;
199     }
200 
201     /**
202      * MD5 hash builder.
203      */
204     @Loggable(Loggable.DEBUG)
205     private static final class Md5 implements Htpasswd.Algorithm {
206         /**
207          * MD5 pattern.
208          */
209         private static final Pattern PATTERN =
210             Pattern.compile("\\$apr1\\$([^\\$]+)\\$([a-zA-Z0-9/\\.]+=*)");
211         @Override
212         public boolean matches(final String hash, final String password) {
213             final Matcher matcher = Htpasswd.Md5.PATTERN.matcher(hash);
214             final boolean matches;
215             if (matcher.matches()) {
216                 final String result = Md5Crypt.apr1Crypt(
217                     password,
218                     matcher.group(1)
219                 );
220                 matches = hash.equals(result);
221             } else {
222                 matches = false;
223             }
224             return matches;
225         }
226     }
227 
228     /**
229      * SHA1 hash builder.
230      */
231     @Loggable(Loggable.DEBUG)
232     private static final class Sha implements Htpasswd.Algorithm {
233         /**
234          * SHA1 pattern.
235          */
236         private static final Pattern PATTERN =
237             Pattern.compile("\\{SHA\\}([a-zA-Z0-9/\\+]+=*)");
238         @Override
239         public boolean matches(final String hash, final String password) {
240             final Matcher matcher = Htpasswd.Sha.PATTERN.matcher(hash);
241             final boolean matches;
242             if (matcher.matches()) {
243                 final String required = Base64.encodeBase64String(
244                     DigestUtils.sha1(password)
245                 );
246                 matches = matcher.group(1).equals(required);
247             } else {
248                 matches = false;
249             }
250             return matches;
251         }
252     }
253 
254     /**
255      * UNIX crypt.
256      */
257     @Loggable(Loggable.DEBUG)
258     private static final class UnixCrypt implements Htpasswd.Algorithm {
259         /**
260          * Unix Crypt pattern.
261          */
262         private static final Pattern PATTERN =
263             Pattern.compile("(\\$[156]\\$)?[a-zA-Z0-9./]+(\\$.*)*");
264         @Override
265         public boolean matches(final String hash, final String password) {
266             return Htpasswd.UnixCrypt.PATTERN.matcher(hash).matches()
267                 && hash.equals(Crypt.crypt(password, hash));
268         }
269     }
270 
271     /**
272      * Plain Text.
273      */
274     @Loggable(Loggable.DEBUG)
275     private static final class PlainText implements Htpasswd.Algorithm {
276         @Override
277         public boolean matches(final String hash, final String password) {
278             return password.equals(hash);
279         }
280     }
281 
282 }