001/*
002 *  Copyright 2017 Anyware Services
003 *
004 *  Licensed under the Apache License, Version 2.0 (the "License");
005 *  you may not use this file except in compliance with the License.
006 *  You may obtain a copy of the License at
007 *
008 *      http://www.apache.org/licenses/LICENSE-2.0
009 *
010 *  Unless required by applicable law or agreed to in writing, software
011 *  distributed under the License is distributed on an "AS IS" BASIS,
012 *  WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
013 *  See the License for the specific language governing permissions and
014 *  limitations under the License.
015 */
016package org.ametys.core.authentication.token;
017
018import java.io.ByteArrayInputStream;
019import java.io.UnsupportedEncodingException;
020import java.sql.Blob;
021import java.sql.Connection;
022import java.sql.PreparedStatement;
023import java.sql.ResultSet;
024import java.sql.SQLException;
025import java.sql.Timestamp;
026import java.sql.Types;
027import java.util.ArrayList;
028import java.util.Base64;
029import java.util.Date;
030import java.util.List;
031import java.util.Map;
032
033import org.ametys.core.datasource.ConnectionHelper;
034import org.ametys.core.ui.Callable;
035import org.ametys.core.user.CurrentUserProvider;
036import org.ametys.core.user.UserIdentity;
037import org.ametys.runtime.config.Config;
038import org.ametys.runtime.plugin.component.AbstractLogEnabled;
039import org.apache.avalon.framework.activity.Initializable;
040import org.apache.avalon.framework.component.Component;
041import org.apache.avalon.framework.service.ServiceException;
042import org.apache.avalon.framework.service.ServiceManager;
043import org.apache.avalon.framework.service.Serviceable;
044import org.apache.commons.codec.digest.DigestUtils;
045import org.apache.commons.lang.RandomStringUtils;
046import org.apache.commons.lang3.StringUtils;
047
048/**
049 * The component to handle temporary authentication token.<br>
050 * Token can only be used once and are available for a short time only.
051 */
052public class AuthenticationTokenManager extends AbstractLogEnabled implements Component, Serviceable, Initializable
053{
054    /** The avalon role */
055    public static final String ROLE = AuthenticationTokenManager.class.getName();
056
057    /** The separator in token */
058    public static final String TOKEN_SEPARATOR = "#";
059
060    /** The user token type */
061    public static final String USER_TOKEN_TYPE = "User";
062
063    private ServiceManager _manager;
064
065    private CurrentUserProvider _currentUserProvider;
066
067    private String _datasourceId;
068
069    public void service(ServiceManager manager) throws ServiceException
070    {
071        _manager = manager;
072    }
073
074    public void initialize() throws Exception
075    {
076        _datasourceId = Config.getInstance() != null ? Config.getInstance().getValueAsString("runtime.assignments.authenticationtokens") : "";
077    }
078
079    private CurrentUserProvider _getCurrentUserProvider() throws RuntimeException
080    {
081        if (_currentUserProvider == null)
082        {
083            try
084            {
085                _currentUserProvider = (CurrentUserProvider) _manager.lookup(CurrentUserProvider.ROLE);
086            }
087            catch (ServiceException e)
088            {
089                throw new RuntimeException(e);
090            }
091        }
092        return _currentUserProvider;
093    }
094
095    /**
096     * Get the existing tokens for the connected user
097     * 
098     * @param type The type of tokens to return. null to return all.
099     * @return The tokens
100     * @throws RuntimeException If there is no user connected or if there is a
101     *             database error
102     */
103    public List<Token> getTokens(String type) throws RuntimeException
104    {
105        return getTokens(_getCurrentUserProvider().getUser(), type);
106    }
107
108    /**
109     * Get the existing tokens for this user
110     * 
111     * @param type The type of tokens to return. null to return all.
112     * @param user The user. Cannot be null
113     * @return The tokens identifier and associated comment
114     * @throws RuntimeException If the user is null or if there is a database
115     *             error
116     */
117    public List<Token> getTokens(UserIdentity user, String type) throws RuntimeException
118    {
119        if (user == null)
120        {
121            throw new RuntimeException("Cannot generate a temporary authentication token for a null user");
122        }
123
124        List<Token> tokens = new ArrayList<>();
125
126        Connection connection = null;
127        try
128        {
129            connection = ConnectionHelper.getConnection(_datasourceId);
130
131            // Delete old entries
132            _deleteOldTokens(connection);
133
134            try (PreparedStatement selectStatement = _getSelectUserTokenStatement(connection, user.getLogin(), user.getPopulationId(), type);
135                    ResultSet resultSet = selectStatement.executeQuery())
136            {
137                // Find the database entry using this token
138                while (resultSet.next())
139                {
140                    Blob commentBlob = resultSet.getBlob("comment");
141                    String comment = new String(commentBlob.getBytes(1, (int) commentBlob.length()));
142                    Token token = new Token(resultSet.getString("id"), resultSet.getString("type"), comment, resultSet.getTimestamp("creation_date"),
143                            resultSet.getTimestamp("end_date"), resultSet.getTimestamp("last_update_date"));
144                    tokens.add(token);
145                }
146            }
147        }
148        catch (Exception e)
149        {
150            getLogger().error("Communication error with the database", e);
151        }
152        finally
153        {
154            ConnectionHelper.cleanup(connection);
155        }
156
157        return tokens;
158    }
159
160    /**
161     * Generates a new token for the current user
162     * 
163     * @param duration The time the token is valid in seconds. 0 means for ever
164     *            and moreover the ticket will be reusable.
165     * @param type The type of token. Mandatory but can be anything you want
166     *            between 1 to 32 characters. Such as "Cookie".
167     * @param comment An optional token comment to remember the reason of its
168     *            creation
169     * @return The token
170     * @throws RuntimeException If the user is not authenticated, or if there is
171     *             a database error
172     */
173    public String generateToken(long duration, String type, String comment) throws RuntimeException
174    {
175        return generateToken(_getCurrentUserProvider().getUser(), duration, type, comment);
176    }
177
178    /**
179     * Generates a new token
180     * 
181     * @param user The user that will be authenticated with the token
182     * @param duration The time the token is valid in seconds. 0 means for ever
183     *            and moreover the ticket will be reusable
184     * @param type The type of token. Mandatory but can be anything you want
185     *            between 1 to 32 characters. Such as "Cookie".
186     * @param comment An optional token comment to remember the reason of its
187     *            creation
188     * @return The token
189     * @throws RuntimeException If the user is null or if there is a database
190     *             error or if duration is negative
191     */
192    @SuppressWarnings("resource")
193    public String generateToken(UserIdentity user, long duration, String type, String comment) throws RuntimeException
194    {
195        if (user == null)
196        {
197            throw new RuntimeException("Cannot generate a temporary authentication token for a null user");
198        }
199        else if (duration < 0)
200        {
201            throw new RuntimeException("Cannot generate a token for a negative duration [" + duration + "]");
202        }
203
204        String token = RandomStringUtils.randomAlphanumeric(duration == 0 ? 64 : 16);
205        String salt = RandomStringUtils.randomAlphanumeric(48);
206        Timestamp creationDateTime = new Timestamp(new Date().getTime());
207        Timestamp endTime = duration > 0 ? new Timestamp(System.currentTimeMillis() + duration * 1000) : null;
208        String hashedTokenAndSalt = DigestUtils.sha512Hex(token + salt);
209
210        Connection connection = null;
211        PreparedStatement statement = null;
212        ResultSet rs = null;
213        try
214        {
215            connection = ConnectionHelper.getConnection(_datasourceId);
216            String dbType = ConnectionHelper.getDatabaseType(connection);
217
218            if (ConnectionHelper.DATABASE_ORACLE.equals(dbType))
219            {
220                statement = connection.prepareStatement("SELECT seq_authenticationtoken.nextval FROM dual");
221                rs = statement.executeQuery();
222
223                String id = null;
224                if (rs.next())
225                {
226                    id = rs.getString(1);
227                }
228                ConnectionHelper.cleanup(rs);
229                ConnectionHelper.cleanup(statement);
230
231                statement = connection.prepareStatement(
232                        "INSERT INTO Authentication_Token (id, login, population_id, token, salt, creation_date, end_date, type, comment) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)");
233                statement.setString(1, id);
234                statement.setString(2, user.getLogin());
235                statement.setString(3, user.getPopulationId());
236                statement.setString(4, hashedTokenAndSalt);
237                statement.setString(5, salt);
238                statement.setTimestamp(6, creationDateTime);
239                statement.setTimestamp(7, endTime);
240                statement.setString(8, type);
241                statement.setBlob(9, comment == null ? null : new ByteArrayInputStream(comment.getBytes("UTF-8")));
242            }
243            else
244            {
245                statement = connection.prepareStatement(
246                        "INSERT INTO Authentication_Token (login, population_id, token, salt, creation_date, end_date, type, comment) VALUES (?, ?, ?, ?, ?, ?, ?, ?)");
247
248                statement.setString(1, user.getLogin());
249                statement.setString(2, user.getPopulationId());
250                statement.setString(3, hashedTokenAndSalt);
251                statement.setString(4, salt);
252                statement.setTimestamp(5, creationDateTime);
253                statement.setTimestamp(6, endTime);
254                statement.setString(7, type);
255                if (comment == null)
256                {
257                    statement.setNull(8, Types.BLOB);
258                }
259                else
260                {
261                    statement.setBlob(8, new ByteArrayInputStream(comment.getBytes("UTF-8")));
262                }
263            }
264
265            statement.executeUpdate();
266        }
267        catch (SQLException | UnsupportedEncodingException e)
268        {
269            throw new RuntimeException(e);
270        }
271        finally
272        {
273            ConnectionHelper.cleanup(rs);
274            ConnectionHelper.cleanup(connection);
275        }
276
277        String fullToken = user.getPopulationId() + TOKEN_SEPARATOR + user.getLogin() + TOKEN_SEPARATOR + token;
278        
279        try
280        {
281            return Base64.getEncoder().withoutPadding().encodeToString(fullToken.getBytes("UTF-8"));
282        }
283        catch (UnsupportedEncodingException e)
284        {
285            // can't occur ...
286            throw new RuntimeException(e);
287        }
288    }
289
290    private UserIdentity _validateToken(String encodedToken, boolean forceRemove)
291    {
292        String token;
293        try
294        {
295            token = new String(Base64.getDecoder().decode(encodedToken), "UTF-8");
296        }
297        catch (UnsupportedEncodingException e)
298        {
299            // can't occur ...
300            throw new RuntimeException(e);
301        }
302        catch (Exception e)
303        {
304            // Exception occured during token decoding
305            return null;
306        }
307        
308        String[] split = StringUtils.split(token, TOKEN_SEPARATOR);
309        if (split == null || split.length != 3)
310        {
311            return null;
312        }
313
314        String populationId = split[0];
315        String login = split[1];
316        String tokenPart = split[2];
317
318        Connection connection = null;
319        try
320        {
321            connection = ConnectionHelper.getConnection(_datasourceId);
322
323            // Delete old entries
324            _deleteOldTokens(connection);
325
326            try (PreparedStatement selectStatement = _getSelectUserTokenStatement(connection, login, populationId, null); ResultSet resultSet = selectStatement.executeQuery())
327            {
328                // Find the database entry using this token
329                while (resultSet.next())
330                {
331                    if (resultSet.getString("token").equals(DigestUtils.sha512Hex(tokenPart + resultSet.getString("salt"))))
332                    {
333                        // Delete it
334                        if (forceRemove || resultSet.getDate("end_date") != null)
335                        {
336                            _deleteUserToken(connection, resultSet.getString("id"));
337                        }
338                        else
339                        {
340                            _updateUserToken(connection, resultSet.getString("id"));
341                        }
342                        return new UserIdentity(login, populationId);
343                    }
344                }
345            }
346        }
347        catch (Exception e)
348        {
349            getLogger().error("Communication error with the database", e);
350        }
351        finally
352        {
353            ConnectionHelper.cleanup(connection);
354        }
355
356        return null;
357    }
358
359    /**
360     * Check if a token is valid and return the s
361     * 
362     * @param token The token to validate
363     * @return The user associated to the valid token, null otherwise
364     */
365    public UserIdentity validateToken(String token)
366    {
367        return _validateToken(token, false);
368    }
369
370    /**
371     * Destroy the given token
372     * 
373     * @param token The token to remove
374     */
375    public void deleteTokenByValue(String token)
376    {
377        _validateToken(token, true);
378    }
379
380    /**
381     * Destroy the given token
382     * 
383     * @param tokenId The token identifier to remove
384     */
385    public void deleteTokenById(String tokenId)
386    {
387        Connection connection = null;
388        try
389        {
390            connection = ConnectionHelper.getConnection(_datasourceId);
391
392            _deleteUserToken(connection, tokenId);
393        }
394        catch (SQLException e)
395        {
396            throw new RuntimeException("Could not delete the authentication token with identifier " + tokenId, e);
397        }
398        finally
399        {
400            ConnectionHelper.cleanup(connection);
401        }
402    }
403
404    /**
405     * Generates the sql statement that deletes the entries of the users token
406     * database that are old
407     * 
408     * @param connection the database's session
409     * @throws SQLException if a sql exception occurs
410     */
411    private void _deleteOldTokens(Connection connection) throws SQLException
412    {
413        try (PreparedStatement statement = connection.prepareStatement("DELETE FROM Authentication_Token WHERE end_date < ?"))
414        {
415            statement.setTimestamp(1, new Timestamp(System.currentTimeMillis()));
416            statement.executeUpdate();
417        }
418    }
419
420    /**
421     * Generates the statement that selects the users having the specified login
422     * in the Users_Token table
423     * 
424     * @param connection the database's session
425     * @param login The login of the user
426     * @param populationId The populationId of the user
427     * @param type The type to filter or null to get all
428     * @return the retrieve statement
429     * @throws SQLException if a sql exception occurs
430     */
431    private PreparedStatement _getSelectUserTokenStatement(Connection connection, String login, String populationId, String type) throws SQLException
432    {
433        String sqlRequest = "SELECT id, token, salt, creation_date, end_date, last_update_date, type, comment FROM Authentication_Token WHERE login=? AND population_id=?"
434                + (type != null ? " AND type=?" : "");
435
436        PreparedStatement statement = connection.prepareStatement(sqlRequest);
437        statement.setString(1, login);
438        statement.setString(2, populationId);
439        if (type != null)
440        {
441            statement.setString(3, type);
442        }
443
444        return statement;
445    }
446
447    /**
448     * Deletes the database entry that has this token
449     * 
450     * @param connection the database's session
451     * @param id the token id
452     * @throws SQLException if an error occurred
453     */
454    private void _deleteUserToken(Connection connection, String id) throws SQLException
455    {
456        try (PreparedStatement statement = connection.prepareStatement("DELETE FROM Authentication_Token WHERE id = ?"))
457        {
458            statement.setString(1, id);
459            statement.executeUpdate();
460        }
461    }
462
463    /**
464     * Update the last update date in the database
465     * 
466     * @param connection the database's session
467     * @param id the token id
468     * @throws SQLException if an error occurred
469     */
470    private void _updateUserToken(Connection connection, String id) throws SQLException
471    {
472        try (PreparedStatement statement = connection.prepareStatement("UPDATE Authentication_Token SET last_update_date = ? WHERE id = ?"))
473        {
474            Timestamp lastUpdateDate = new Timestamp(new Date().getTime());
475            statement.setTimestamp(1, lastUpdateDate);
476            statement.setString(2, id);
477            statement.executeUpdate();
478        }
479    }
480
481    /**
482     * Generate a new authentication token
483     * 
484     * @param parameters a map of the following parameters for the
485     *            authentication token : description
486     * @return The generated token
487     */
488    @Callable
489    public String generateAuthenticationToken(Map<String, Object> parameters)
490    {
491        String description = (String) parameters.get("description");
492        String generateToken = generateToken(0, USER_TOKEN_TYPE, description);
493
494        return generateToken;
495    }
496
497    /**
498     * Delete one or multiples authentication token
499     * 
500     * @param ids a list of authentication token ids
501     */
502    @Callable
503    public void deleteAuthenticationToken(List<String> ids)
504    {
505        for (String tokenId : ids)
506        {
507            deleteTokenById(tokenId);
508        }
509    }
510
511    /**
512     * An Ametys authentication token
513     */
514    public static class Token
515    {
516        /** The token identifier */
517        protected String _id;
518
519        /** The token type */
520        protected String _type;
521
522        /** The token associated comment */
523        protected String _comment;
524
525        /** The token creation date */
526        protected Date _creationDate;
527
528        /** The token end date */
529        protected Date _endDate;
530
531        /** The token last update date */
532        protected Date _lastUpdateDate;
533
534        /**
535         * Creates a Token
536         * 
537         * @param id The identifier
538         * @param type The type of token
539         * @param comment The comment. Can be null.
540         * @param creationDate The creation date. Can be null.
541         * @param endDate The end date. Can be null.
542         * @param lastUpdateDate The last update date. Can be null.
543         */
544        protected Token(String id, String type, String comment, Date creationDate, Date endDate, Date lastUpdateDate)
545        {
546            _id = id;
547            _type = type;
548            _comment = comment;
549            _creationDate = creationDate;
550            _endDate = endDate;
551            _lastUpdateDate = lastUpdateDate;
552        }
553
554        /**
555         * Get the token identifier
556         * 
557         * @return The identifier
558         */
559        public String getId()
560        {
561            return _id;
562        }
563
564        /**
565         * Get the token type
566         * 
567         * @return The type
568         */
569        public String getType()
570        {
571            return _type;
572        }
573
574        /**
575         * Get the associated creation comment
576         * 
577         * @return The comment or null
578         */
579        public String getComment()
580        {
581            return _comment;
582        }
583
584        /**
585         * Get the creation date of the token
586         * 
587         * @return The creation date or null
588         */
589        public Date getCreationDate()
590        {
591            return _creationDate;
592        }
593
594        /**
595         * Get the end date of the token validity
596         * 
597         * @return The end date or null
598         */
599        public Date getEndDate()
600        {
601            return _endDate;
602        }
603
604        /**
605         * Get the last update date of the token
606         * 
607         * @return The last update date or null
608         */
609        public Date getLastUpdateDate()
610        {
611            return _endDate;
612        }
613    }
614}