001/**
002 * Copyright (c) 2011, The University of Southampton and the individual contributors.
003 * All rights reserved.
004 *
005 * Redistribution and use in source and binary forms, with or without modification,
006 * are permitted provided that the following conditions are met:
007 *
008 *   *  Redistributions of source code must retain the above copyright notice,
009 *      this list of conditions and the following disclaimer.
010 *
011 *   *  Redistributions in binary form must reproduce the above copyright notice,
012 *      this list of conditions and the following disclaimer in the documentation
013 *      and/or other materials provided with the distribution.
014 *
015 *   *  Neither the name of the University of Southampton nor the names of its
016 *      contributors may be used to endorse or promote products derived from this
017 *      software without specific prior written permission.
018 *
019 * THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" AND
020 * ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED
021 * WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE
022 * DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT OWNER OR CONTRIBUTORS BE LIABLE FOR
023 * ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES
024 * (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES;
025 * LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON
026 * ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT
027 * (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE OF THIS
028 * SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE.
029 */
030package org.openimaj.util.api.auth;
031
032import java.io.BufferedReader;
033import java.io.IOException;
034import java.io.InputStreamReader;
035import java.lang.reflect.Field;
036import java.util.ArrayList;
037import java.util.Collection;
038import java.util.HashMap;
039import java.util.List;
040import java.util.Map;
041import java.util.Map.Entry;
042import java.util.prefs.BackingStoreException;
043import java.util.prefs.Preferences;
044
045import org.apache.commons.lang.ArrayUtils;
046import org.apache.commons.lang.WordUtils;
047
048/**
049 * Default implementation of a {@link TokenFactory} that loads the token
050 * parameters from the default Java user preference store or interactively
051 * queries the user for the required token parameters if the token has not been
052 * used before.
053 * <p>
054 * Interactive querying is performed via the command-line (using
055 * {@link System#err} for prompts and {@link System#in} for reading user input.
056 * As such, this class will only be really useful for interactive querying in
057 * console applications. It is possible however to just use this class for
058 * manually storing and retrieving tokens with the appropriate methods.
059 * <p>
060 * For this class to work in interactive mode, the token class must have a
061 * public no-args constructor.
062 * 
063 * @author Jonathon Hare (jsh2@ecs.soton.ac.uk)
064 * 
065 */
066public class DefaultTokenFactory implements TokenFactory {
067        private static final DefaultTokenFactory instance = new DefaultTokenFactory();
068        private static final String PREFS_BASE_NODE = "/org/openimaj/util/api/auth";
069
070        private DefaultTokenFactory() {
071        }
072
073        /**
074         * Get the default singleton instance
075         * 
076         * @return the default instance
077         */
078        public static DefaultTokenFactory getInstance() {
079                return instance;
080        }
081
082        /**
083         * Delete the default token parameters for the given class from the store.
084         * 
085         * @param tokenClass
086         *            the token class
087         * @throws BackingStoreException
088         *             if a problem occurred communicating with the backing
089         *             preference store
090         */
091        public <T> void deleteToken(Class<T> tokenClass) throws BackingStoreException {
092                deleteToken(tokenClass, null);
093        }
094
095        /**
096         * Delete the named token parameters for the given class from the store.
097         * 
098         * @param tokenClass
099         *            the token class
100         * @param name
101         *            the name of the token, or <tt>null</tt> for the default token
102         * @throws BackingStoreException
103         *             if a problem occurred communicating with the backing
104         *             preference store
105         */
106        public <T> void deleteToken(Class<T> tokenClass, String name) throws BackingStoreException {
107                final String tokName = name == null ? tokenClass.getName() : tokenClass.getName() + "-" + name;
108                final Preferences base = Preferences.userRoot().node(PREFS_BASE_NODE);
109
110                base.node(tokName).removeNode();
111
112                base.sync();
113        }
114
115        @Override
116        public <T> T getToken(Class<T> tokenClass) {
117                return getToken(tokenClass, null);
118        }
119
120        @Override
121        public <T> T getToken(Class<T> tokenClass, String name) {
122                final Token tokenDef = tokenClass.getAnnotation(Token.class);
123
124                if (tokenDef == null)
125                        throw new IllegalArgumentException("The provided class is not annotated with @Token");
126
127                try {
128                        T token = loadToken(tokenClass, name);
129
130                        if (token == null) {
131                                token = createToken(tokenDef, tokenClass, name);
132                        }
133
134                        return token;
135                } catch (final Exception e) {
136                        throw new RuntimeException(e);
137                }
138        }
139
140        private String getMessage(Token def, Map<Field, Parameter> params) {
141                String msg = String.format("You do not appear to have any credentials stored for the %s. ", def.name()) +
142                                String.format("To use the %s you need to have a %s.\n", def.name(), formatParams(params.values())) +
143                                String.format("You can get these from %s.\n\n", def.url());
144
145                if (def.extraInfo() != null && def.extraInfo().length() > 0)
146                        msg += String.format(def.extraInfo() + "\n\n");
147
148                msg += String.format("To continue please enter the credentials as indicated. ");
149                msg += String.format("These will be stored automatically for future use.");
150
151                return msg;
152        }
153
154        private <T> T createToken(Token def, Class<T> clz, String name) throws InstantiationException,
155                        IllegalAccessException,
156                        IOException, IllegalArgumentException, BackingStoreException
157        {
158                final Map<Field, Parameter> params = getParameters(clz);
159
160                final T instance = clz.newInstance();
161                if (params.size() == 0)
162                        return instance;
163
164                System.err.format(WordUtils.wrap(getMessage(def, params) + "\n\n", 80));
165
166                final BufferedReader br = new BufferedReader(new InputStreamReader(System.in));
167
168                final Map<Parameter, String> inputs = new HashMap<Parameter, String>();
169                for (final Entry<Field, Parameter> entry : params.entrySet()) {
170                        query(instance, entry.getKey(), entry.getValue(), br, inputs);
171                }
172
173                System.err.println("\n\n");
174                System.err.println("The following parameters have been set:\n");
175                for (final Entry<Field, Parameter> entry : params.entrySet()) {
176                        System.err.println(entry.getValue().name() + ": " + inputs.get(entry.getValue()) + "\n");
177                }
178
179                while (true) {
180                        System.err.println("\nPlease confirm the parameters are correct (Y/N): ");
181                        if (br.readLine().trim().equalsIgnoreCase("y")) {
182                                break;
183                        } else if (br.readLine().trim().equalsIgnoreCase("n")) {
184                                return createToken(def, clz, name);
185                        }
186                }
187
188                saveToken(instance, name);
189
190                return instance;
191        }
192
193        /**
194         * Save the parameters of the given token to the backing preference store.
195         * 
196         * @param token
197         *            the token to save
198         * @param name
199         *            the name of the token, or <tt>null</tt> for the default
200         * @throws IllegalArgumentException
201         *             if the token class isn't annotated with {@link Token}.
202         * @throws IllegalAccessException
203         *             if an error occurred reading a parameter of the token
204         * @throws BackingStoreException
205         *             if a problem occurred communicating with the backing
206         *             preference store
207         */
208        public <T> void saveToken(T token, String name) throws IllegalArgumentException, IllegalAccessException,
209                        BackingStoreException
210        {
211
212                final Class<?> tokenClass = token.getClass();
213                final String tokName = name == null ? tokenClass.getName() : tokenClass.getName() + "-" + name;
214
215                final Token tokenDef = tokenClass.getAnnotation(Token.class);
216
217                if (tokenDef == null)
218                        throw new IllegalArgumentException("The provided class is not annotated with @Token");
219
220                final Preferences prefs = Preferences.userRoot().node(PREFS_BASE_NODE).node(tokName);
221                final Map<Field, Parameter> params = getParameters(tokenClass);
222
223                for (final Entry<Field, Parameter> entry : params.entrySet()) {
224                        final Field f = entry.getKey();
225
226                        if (f.getType() == Integer.class) {
227                                prefs.putInt(f.getName(), (Integer) f.get(token));
228                        } else if (f.getType() == Integer.TYPE) {
229                                prefs.putInt(f.getName(), f.getInt(token));
230                        } else if (f.getType() == Long.class) {
231                                prefs.putLong(f.getName(), (Long) f.get(token));
232                        } else if (f.getType() == Long.TYPE) {
233                                prefs.putLong(f.getName(), f.getLong(token));
234                        } else if (f.getType() == Double.class) {
235                                prefs.putDouble(f.getName(), (Double) f.get(token));
236                        } else if (f.getType() == Double.TYPE) {
237                                prefs.putDouble(f.getName(), f.getDouble(token));
238                        } else if (f.getType() == Float.class) {
239                                prefs.putFloat(f.getName(), (Float) f.get(token));
240                        } else if (f.getType() == Float.TYPE) {
241                                prefs.putFloat(f.getName(), f.getFloat(token));
242                        } else if (f.getType() == String.class) {
243                                prefs.put(f.getName(), (String) f.get(token));
244                        } else if (f.getType() == byte[].class) {
245                                prefs.putByteArray(f.getName(), (byte[]) f.get(token));
246                        }
247                }
248
249                prefs.sync();
250        }
251
252        private void query(Object instance, Field f, Parameter p, BufferedReader br, Map<Parameter, String> rawInputs)
253                        throws IOException,
254                        IllegalArgumentException, IllegalAccessException
255        {
256
257                while (true) {
258                        System.err.format("Please enter your %s:\n", p.name());
259                        final String input = br.readLine().trim();
260
261                        rawInputs.put(p, input);
262
263                        if (setValue(instance, f, input))
264                                return;
265
266                        System.err.format("Sorry, %s doesn't appear to be the correct format for the %s (hint: expecting a %s).\n",
267                                        input, p.name(), getType(f));
268                }
269        }
270
271        private boolean setValue(Object instance, Field f, String input) throws IllegalArgumentException,
272                        IllegalAccessException
273        {
274                try {
275                        if (f.getType() == Integer.class)
276                                f.set(instance, Integer.parseInt(input));
277                        else if (f.getType() == Integer.TYPE)
278                                f.setInt(instance, Integer.parseInt(input));
279                        if (f.getType() == Long.class)
280                                f.set(instance, Long.parseLong(input));
281                        else if (f.getType() == Long.TYPE)
282                                f.setLong(instance, Long.parseLong(input));
283                        if (f.getType() == Double.class)
284                                f.set(instance, Double.parseDouble(input));
285                        else if (f.getType() == Double.TYPE)
286                                f.setDouble(instance, Double.parseDouble(input));
287                        if (f.getType() == Float.class)
288                                f.set(instance, Float.parseFloat(input));
289                        else if (f.getType() == Float.TYPE)
290                                f.setFloat(instance, Float.parseFloat(input));
291                        if (f.getType() == String.class)
292                                f.set(instance, input);
293                        if (f.getType() == byte[].class)
294                                f.set(instance, input.getBytes());
295                } catch (final NumberFormatException nfe) {
296                        return false;
297                }
298
299                return true;
300        }
301
302        private String getType(Field f) {
303                if (f.getType() == Integer.class || f.getType() == Integer.TYPE)
304                        return "integer";
305                if (f.getType() == Long.class || f.getType() == Long.TYPE)
306                        return "long";
307                if (f.getType() == Double.class || f.getType() == Double.TYPE)
308                        return "double";
309                if (f.getType() == Float.class || f.getType() == Float.TYPE)
310                        return "float";
311                if (f.getType() == String.class)
312                        return "string";
313                if (f.getType() == byte[].class)
314                        return "byte array";
315
316                throw new UnsupportedOperationException("Unsupported field type " + f.getType() + " for field "
317                                + f.getName());
318        }
319
320        private String formatParams(Collection<Parameter> values) {
321                final List<Parameter> paramsList = new ArrayList<Parameter>(values);
322
323                if (values.size() == 1)
324                        return paramsList.get(0).name();
325
326                final StringBuilder sb = new StringBuilder();
327                for (int i = 0; i < paramsList.size() - 2; i++)
328                        sb.append(paramsList.get(i).name() + ", ");
329                sb.append(paramsList.get(paramsList.size() - 2).name());
330                sb.append(" and ");
331                sb.append(paramsList.get(paramsList.size() - 1).name());
332
333                return sb.toString();
334        }
335
336        private Map<Field, Parameter> getParameters(Class<?> clz) {
337                final Map<Field, Parameter> fields = new HashMap<Field, Parameter>();
338
339                while (clz != null) {
340                        for (final Field f : clz.getDeclaredFields()) {
341                                final Parameter p = f.getAnnotation(Parameter.class);
342                                if (p != null) {
343                                        f.setAccessible(true);
344                                        fields.put(f, p);
345                                }
346                        }
347
348                        clz = clz.getSuperclass();
349                }
350
351                return fields;
352        }
353
354        /**
355         * Load a token with an optional name tag from the backing store.
356         * 
357         * @param clz
358         *            the class of the token
359         * @param name
360         *            the name of the token, or <tt>null</tt> for the default token
361         * @return a token loaded with the previously saved parameters, or
362         *         <tt>null</tt> if the token could not be read
363         * @throws BackingStoreException
364         *             if a problem occurred communicating with the backing
365         *             preference store
366         * @throws InstantiationException
367         *             if the token could not be constructed
368         * @throws IllegalAccessException
369         *             if an error occurred setting a parameter
370         */
371        public <T> T loadToken(Class<T> clz, String name) throws BackingStoreException, InstantiationException,
372                        IllegalAccessException
373        {
374                final String tokName = name == null ? clz.getName() : clz.getName() + "-" + name;
375                Preferences prefs = Preferences.userRoot().node(PREFS_BASE_NODE);
376
377                if (!prefs.nodeExists(tokName))
378                        return null;
379
380                prefs = prefs.node(tokName);
381                final String[] keys = prefs.keys();
382
383                final T instance = clz.newInstance();
384
385                final Map<Field, Parameter> params = getParameters(clz);
386                for (final Entry<Field, Parameter> p : params.entrySet()) {
387                        final Field field = p.getKey();
388                        final Parameter parameter = p.getValue();
389
390                        if (!ArrayUtils.contains(keys, field.getName()))
391                                return null; // missing value in store, so force new one to be
392                                                                // created
393
394                        loadValue(instance, field, parameter, prefs);
395                }
396
397                return instance;
398        }
399
400        private <T> void loadValue(T instance, Field field, Parameter parameter, Preferences node)
401                        throws IllegalArgumentException, IllegalAccessException
402        {
403                final String fieldName = field.getName();
404
405                if (field.getType() == Integer.TYPE) {
406                        field.setInt(instance, node.getInt(fieldName, 0));
407                } else if (field.getType() == Integer.class) {
408                        field.set(instance, node.getInt(fieldName, 0));
409                } else if (field.getType() == Long.TYPE) {
410                        field.setLong(instance, node.getLong(fieldName, 0));
411                } else if (field.getType() == Long.class) {
412                        field.set(instance, node.getLong(fieldName, 0));
413                } else if (field.getType() == Double.TYPE) {
414                        field.setDouble(instance, node.getDouble(fieldName, 0));
415                } else if (field.getType() == Double.class) {
416                        field.set(instance, node.getDouble(fieldName, 0));
417                } else if (field.getType() == Float.TYPE) {
418                        field.setFloat(instance, node.getFloat(fieldName, 0));
419                } else if (field.getType() == Float.class) {
420                        field.set(instance, node.getFloat(fieldName, 0));
421                } else if (field.getType() == Boolean.TYPE) {
422                        field.setBoolean(instance, node.getBoolean(fieldName, false));
423                } else if (field.getType() == Boolean.class) {
424                        field.set(instance, node.getBoolean(fieldName, false));
425                } else if (field.getType() == String.class) {
426                        field.set(instance, node.get(fieldName, null));
427                } else if (field.getType() == byte[].class) {
428                        field.set(instance, node.getByteArray(fieldName, null));
429                } else {
430                        throw new UnsupportedOperationException("Unsupported field type " + field.getType() + " for field "
431                                        + fieldName);
432                }
433        }
434
435        /**
436         * Convenience method equivalent to
437         * <tt>getInstance().getToken(tokenClass)</tt>.
438         * 
439         * @see #getToken(Class)
440         * @param tokenClass
441         *            the class of the token to build
442         * @return the token
443         */
444        public static <T> T get(Class<T> tokenClass) {
445                return getInstance().getToken(tokenClass);
446        }
447
448        /**
449         * Convenience method equivalent to
450         * <tt>getInstance().getToken(tokenClass, name)</tt>.
451         * 
452         * @see #getToken(Class, String)
453         * @param tokenClass
454         *            the class of the token to build
455         * @param name
456         *            the name of the token
457         * @return the token
458         */
459        public static <T> T get(Class<T> tokenClass, String name) {
460                return getInstance().getToken(tokenClass);
461        }
462        
463        /**
464         * Convenience method equivalent to
465         * {@code getInstance().deleteToken(tokenClass) }
466         * 
467         * @see #deleteToken(Class)
468         * @param tokenClass the class of the token to delete
469         * @throws BackingStoreException
470         *             if a problem occurred communicating with the backing
471         *             preference store
472         */
473        public static <T> void delete(Class<T> tokenClass) throws BackingStoreException {
474                getInstance().deleteToken(tokenClass);
475        }
476        
477        /**
478         * Convenience method equivalent to
479         * {@code getInstance().deleteToken(tokenClass, name) }
480         * 
481         * @see #deleteToken(Class, String)
482         * @param tokenClass the class of the token to delete
483         * @param name the name of the token, or {@code null} for the default token
484         * @throws BackingStoreException
485         *             if a problem occurred communicating with the backing
486         *             preference store
487         */
488        public static <T> void delete(Class<T> tokenClass, String name) throws BackingStoreException {
489                getInstance().deleteToken(tokenClass, name);
490        }
491}