Should return null if user is not valid.
[guacamole.git] / src / main / java / net / sourceforge / guacamole / net / basic / BasicFileAuthenticationProvider.java
1
2 package net.sourceforge.guacamole.net.basic;
3
4 /*
5  *  Guacamole - Clientless Remote Desktop
6  *  Copyright (C) 2010  Michael Jumper
7  *
8  *  This program is free software: you can redistribute it and/or modify
9  *  it under the terms of the GNU Affero General Public License as published by
10  *  the Free Software Foundation, either version 3 of the License, or
11  *  (at your option) any later version.
12  *
13  *  This program is distributed in the hope that it will be useful,
14  *  but WITHOUT ANY WARRANTY; without even the implied warranty of
15  *  MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
16  *  GNU Affero General Public License for more details.
17  *
18  *  You should have received a copy of the GNU Affero General Public License
19  *  along with this program.  If not, see <http://www.gnu.org/licenses/>.
20  */
21
22 import java.io.BufferedReader;
23 import net.sourceforge.guacamole.net.auth.AuthenticationProvider;
24 import java.io.File;
25 import java.io.FileReader;
26 import java.io.IOException;
27 import java.io.Reader;
28 import java.security.MessageDigest;
29 import java.security.NoSuchAlgorithmException;
30 import java.util.Collections;
31 import java.util.HashMap;
32 import java.util.Map;
33 import net.sourceforge.guacamole.GuacamoleException;
34 import net.sourceforge.guacamole.net.auth.UsernamePassword;
35 import net.sourceforge.guacamole.properties.FileGuacamoleProperty;
36 import net.sourceforge.guacamole.properties.GuacamoleProperties;
37 import net.sourceforge.guacamole.protocol.GuacamoleConfiguration;
38 import org.slf4j.Logger;
39 import org.slf4j.LoggerFactory;
40 import org.xml.sax.Attributes;
41 import org.xml.sax.InputSource;
42 import org.xml.sax.SAXException;
43 import org.xml.sax.XMLReader;
44 import org.xml.sax.helpers.DefaultHandler;
45 import org.xml.sax.helpers.XMLReaderFactory;
46
47 /**
48  * Authenticates users against a static list of username/password pairs.
49  * Each username/password may be associated with exactly one configuration.
50  * This list is stored in an XML file which is reread if modified.
51  * 
52  * @author Michael Jumper
53  */
54 public class BasicFileAuthenticationProvider implements AuthenticationProvider<UsernamePassword> {
55
56     private Logger logger = LoggerFactory.getLogger(BasicFileAuthenticationProvider.class);
57     
58     private long mappingTime;
59     private Map<String, AuthInfo> mapping;
60
61     /**
62      * The filename of the XML file to read the user mapping from.
63      */
64     public static final FileGuacamoleProperty BASIC_USER_MAPPING = new FileGuacamoleProperty() {
65
66         @Override
67         public String getName() { return "basic-user-mapping"; }
68
69     };
70
71     private File getUserMappingFile() throws GuacamoleException {
72
73         // Get user mapping file
74         return GuacamoleProperties.getProperty(BASIC_USER_MAPPING);
75
76     }
77
78     public synchronized void init() throws GuacamoleException {
79
80         // Get user mapping file
81         File mapFile = getUserMappingFile();
82         if (mapFile == null)
83             throw new GuacamoleException("Missing \"basic-user-mapping\" parameter required for basic login.");
84
85         logger.info("Reading user mapping file: {}", mapFile);
86         
87         // Parse document
88         try {
89
90             // Set up parser
91             BasicUserMappingContentHandler contentHandler = new BasicUserMappingContentHandler();
92
93             XMLReader parser = XMLReaderFactory.createXMLReader();
94             parser.setContentHandler(contentHandler);
95
96             // Read and parse file
97             Reader reader = new BufferedReader(new FileReader(mapFile));
98             parser.parse(new InputSource(reader));
99             reader.close();
100
101             // Init mapping and record mod time of file
102             mappingTime = mapFile.lastModified();
103             mapping = contentHandler.getUserMapping();
104
105         }
106         catch (IOException e) {
107             throw new GuacamoleException("Error reading basic user mapping file.", e);
108         }
109         catch (SAXException e) {
110             throw new GuacamoleException("Error parsing basic user mapping XML.", e);
111         }
112
113     }
114
115     @Override
116     public Map<String, GuacamoleConfiguration> getAuthorizedConfigurations(UsernamePassword credentials) throws GuacamoleException {
117
118         // Check mapping file mod time
119         File userMappingFile = getUserMappingFile();
120         if (userMappingFile.exists() && mappingTime < userMappingFile.lastModified()) {
121
122             // If modified recently, gain exclusive access and recheck
123             synchronized (this) {
124                 if (userMappingFile.exists() && mappingTime < userMappingFile.lastModified()) {
125                     logger.info("User mapping file {} has been modified.", userMappingFile);
126                     init(); // If still not up to date, re-init
127                 }
128             }
129
130         }
131
132         // If no mapping available, report as such
133         if (mapping == null)
134             throw new GuacamoleException("User mapping could not be read.");
135         
136         Map<String, GuacamoleConfiguration> configs = new HashMap<String, GuacamoleConfiguration>();
137         
138         // Validate and return info for given user and pass
139         AuthInfo info = mapping.get(credentials.getUsername());
140         if (info != null && info.validate(credentials.getUsername(), credentials.getPassword())) {
141             configs.put("DEFAULT", info.getConfiguration());
142             return configs;
143         }
144
145         // Unauthorized
146         return null;
147
148     }
149
150     public static class AuthInfo {
151
152         public static enum Encoding {
153             PLAIN_TEXT,
154             MD5
155         }
156
157         private String auth_username;
158         private String auth_password;
159         private Encoding auth_encoding;
160
161         private GuacamoleConfiguration config;
162
163         public AuthInfo(String auth_username, String auth_password, Encoding auth_encoding) {
164             this.auth_username = auth_username;
165             this.auth_password = auth_password;
166             this.auth_encoding = auth_encoding;
167
168             config = new GuacamoleConfiguration();
169         }
170
171         private static final char HEX_CHARS[] = {
172             '0', '1', '2', '3', '4', '5', '6', '7',
173             '8', '9', 'A', 'B', 'C', 'D', 'E', 'F'
174         };
175
176         public static String getHexString(byte[] bytes) {
177
178             if (bytes == null)
179                 return null;
180
181             StringBuilder hex = new StringBuilder(2 * bytes.length);
182             for (byte b : bytes) {
183                 hex.append(HEX_CHARS[(b & 0xF0) >> 4])
184                    .append(HEX_CHARS[(b & 0x0F)     ]);
185             }
186
187             return hex.toString();
188
189         }
190
191
192         public boolean validate(String username, String password) {
193
194             // If username matches
195             if (username != null && password != null && username.equals(auth_username)) {
196
197                 switch (auth_encoding) {
198
199                     case PLAIN_TEXT:
200
201                         // Compare plaintext
202                         return password.equals(auth_password);
203
204                     case MD5:
205
206                         // Compare hashed password
207                         try {
208                             MessageDigest digest = MessageDigest.getInstance("MD5");
209                             String hashedPassword = getHexString(digest.digest(password.getBytes()));
210                             return hashedPassword.equals(auth_password.toUpperCase());
211                         }
212                         catch (NoSuchAlgorithmException e) {
213                             throw new UnsupportedOperationException("Unexpected lack of MD5 support.", e);
214                         }
215
216                 }
217
218             }
219
220             return false;
221
222         }
223
224         public GuacamoleConfiguration getConfiguration() {
225             return config;
226         }
227
228     }
229
230
231     private static class BasicUserMappingContentHandler extends DefaultHandler {
232
233         private Map<String, AuthInfo> authMapping = new HashMap<String, AuthInfo>();
234
235         public Map<String, AuthInfo> getUserMapping() {
236             return Collections.unmodifiableMap(authMapping);
237         }
238
239         private enum State {
240             ROOT,
241             USER_MAPPING,
242             AUTH_INFO,
243             PROTOCOL,
244             PARAMETER,
245             END;
246         }
247
248         private State state = State.ROOT;
249         private AuthInfo current = null;
250         private String currentParameter = null;
251
252         @Override
253         public void endElement(String uri, String localName, String qName) throws SAXException {
254
255             switch (state)  {
256
257                 case USER_MAPPING:
258
259                     if (localName.equals("user-mapping")) {
260                         state = State.END;
261                         return;
262                     }
263
264                     break;
265
266                 case AUTH_INFO:
267
268                     if (localName.equals("authorize")) {
269
270                         // Finalize mapping for this user
271                         authMapping.put(
272                             current.auth_username,
273                             current
274                         );
275
276                         state = State.USER_MAPPING;
277                         return;
278                     }
279
280                     break;
281
282                 case PROTOCOL:
283
284                     if (localName.equals("protocol")) {
285                         state = State.AUTH_INFO;
286                         return;
287                     }
288
289                     break;
290
291                 case PARAMETER:
292
293                     if (localName.equals("param")) {
294                         state = State.AUTH_INFO;
295                         return;
296                     }
297
298                     break;
299
300             }
301
302             throw new SAXException("Tag not yet complete: " + localName);
303
304         }
305
306         @Override
307         public void startElement(String uri, String localName, String qName, Attributes attributes) throws SAXException {
308
309             switch (state)  {
310
311                 // Document must be <user-mapping>
312                 case ROOT:
313
314                     if (localName.equals("user-mapping")) {
315                         state = State.USER_MAPPING;
316                         return;
317                     }
318
319                     break;
320
321                 // Only <authorize> tags allowed in main document
322                 case USER_MAPPING:
323
324                     if (localName.equals("authorize")) {
325
326                         AuthInfo.Encoding encoding;
327                         String encodingString = attributes.getValue("encoding");
328                         if (encodingString == null)
329                             encoding = AuthInfo.Encoding.PLAIN_TEXT;
330                         else if (encodingString.equals("plain"))
331                             encoding = AuthInfo.Encoding.PLAIN_TEXT;
332                         else if (encodingString.equals("md5"))
333                             encoding = AuthInfo.Encoding.MD5;
334                         else
335                             throw new SAXException("Invalid encoding type");
336
337
338                         current = new AuthInfo(
339                             attributes.getValue("username"),
340                             attributes.getValue("password"),
341                             encoding
342                         );
343
344                         // Next state
345                         state = State.AUTH_INFO;
346                         return;
347                     }
348
349                     break;
350
351                 case AUTH_INFO:
352
353                     if (localName.equals("protocol")) {
354                         // Next state
355                         state = State.PROTOCOL;
356                         return;
357                     }
358
359                     if (localName.equals("param")) {
360
361                         currentParameter = attributes.getValue("name");
362                         if (currentParameter == null)
363                             throw new SAXException("Attribute \"name\" required for param tag.");
364
365                         // Next state
366                         state = State.PARAMETER;
367                         return;
368                     }
369
370                     break;
371
372             }
373
374             throw new SAXException("Unexpected tag: " + localName);
375
376         }
377
378         @Override
379         public void characters(char[] ch, int start, int length) throws SAXException {
380
381             String str = new String(ch, start, length);
382             switch (state) {
383
384                 case PROTOCOL:
385                     current.getConfiguration()
386                             .setProtocol(str);
387                     return;
388
389                 case PARAMETER:
390                     current.getConfiguration()
391                             .setParameter(currentParameter, str);
392                     return;
393                 
394             }
395
396             if (str.trim().length() != 0)
397                 throw new SAXException("Unexpected character data.");
398
399         }
400
401
402     }
403
404
405 }