001/**
002 * Copyright 2014 Tampere University of Technology, Pori Department
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 service.tut.pori.users;
017
018import java.util.Collection;
019import java.util.EnumSet;
020import java.util.HashMap;
021import java.util.List;
022import java.util.Map;
023import java.util.Map.Entry;
024
025import org.apache.commons.lang3.StringUtils;
026import org.apache.log4j.Logger;
027import org.springframework.jdbc.core.JdbcTemplate;
028import org.springframework.jdbc.core.simple.SimpleJdbcInsert;
029import org.springframework.security.core.GrantedAuthority;
030import org.springframework.transaction.TransactionStatus;
031import org.springframework.transaction.support.TransactionCallback;
032
033import core.tut.pori.dao.clause.AndClause;
034import core.tut.pori.dao.clause.SQLClause.SQLType;
035import core.tut.pori.dao.SQLDAO;
036import core.tut.pori.dao.SQLSelectBuilder;
037import core.tut.pori.users.ExternalAccountConnection;
038import core.tut.pori.users.UserAuthority;
039import core.tut.pori.users.ExternalAccountConnection.UserServiceType;
040import core.tut.pori.users.UserIdentity;
041
042/**
043 * DAO for retrieving user details, and for creating new users. This class can also be used to modify existing users as well as list and modify user's external account connections.
044 *
045 */
046public class UserDAO extends SQLDAO{
047  private static final Logger LOGGER = Logger.getLogger(UserDAO.class);
048  /* tables */
049  private static final String TABLE_USERS = DATABASE+".users";
050  private static final String TABLE_USERS_EXTERNAL_IDS = DATABASE+".users_external_ids";
051  private static final String TABLE_USERS_ROLES = DATABASE+".users_roles";
052  /* columns */
053  private static final String COLUMN_EXTERNAL_ID = "external_id";
054  private static final String COLUMN_PASSWORD_HASH = "password_hash";
055  private static final String COLUMN_ROLE = "role";
056  private static final String COLUMN_USERNAME = "username";
057  private static final String COLUMN_USER_SERVICE_ID = "user_service_id";
058  /* sql strings */
059  private static final String SQL_ADD_USER_ROLE = "INSERT INTO "+TABLE_USERS_ROLES+" ("+COLUMN_USER_ID+", "+COLUMN_ROLE+", "+COLUMN_ROW_CREATED+") VALUES (?,?,NOW()) ON DUPLICATE KEY UPDATE "+COLUMN_ROW_UPDATED+"=NOW()";
060  private static final int[] SQL_ADD_USER_ROLE_SQL_TYPES = {SQLType.LONG.toInt(), SQLType.STRING.toInt()};
061  
062  private static final String SQL_DELETE_EXTERNAL_ACCOUNT_CONNECTION = "DELETE FROM "+TABLE_USERS_EXTERNAL_IDS+" WHERE "+COLUMN_USER_ID+"=? AND "+COLUMN_USER_SERVICE_ID+"=?";
063  private static final int[] SQL_DELETE_EXTERNAL_ACCOUNT_CONNECTION_TYPES = {SQLType.LONG.toInt(), SQLType.INTEGER.toInt()};
064  
065  private static final String[] SQL_GET_EXTERNAL_ACCOUNT_CONNECTIONS_COLUMNS = {COLUMN_EXTERNAL_ID, COLUMN_USER_SERVICE_ID};
066  
067  private static final String SQL_GET_EXTERNAL_ID = "SELECT "+COLUMN_EXTERNAL_ID+" FROM "+TABLE_USERS_EXTERNAL_IDS+" WHERE "+COLUMN_USER_ID+"=? AND "+COLUMN_USER_SERVICE_ID+"=? LIMIT 1";
068  private static final int[] SQL_GET_EXTERNAL_ID_SQL_TYPES = {SQLType.LONG.toInt(), SQLType.INTEGER.toInt()};
069  
070  private static final String SQL_GET_USER_ID = "SELECT "+COLUMN_COUNT+", "+COLUMN_USER_ID+" FROM "+TABLE_USERS_EXTERNAL_IDS+" WHERE "+COLUMN_EXTERNAL_ID+"=? AND "+COLUMN_USER_SERVICE_ID+"=? LIMIT 1"; // count added to force result
071  private static final int[] SQL_GET_USER_ID_SQL_TYPES = {SQLType.STRING.toInt(), SQLType.INTEGER.toInt()};
072  
073  private static final String SQL_GET_USER_ROLES = "SELECT "+COLUMN_ROLE+" FROM "+TABLE_USERS_ROLES+" WHERE "+COLUMN_USER_ID+"=?";
074  private static final int[] SQL_GET_USER_ROLES_SQL_TYPES = {SQLType.LONG.toInt()};
075  
076  private static final String SQL_INSERT_EXTERNAL_ID = "INSERT INTO "+TABLE_USERS_EXTERNAL_IDS+" ("+COLUMN_USER_ID+", "+COLUMN_EXTERNAL_ID+", "+COLUMN_USER_SERVICE_ID+", "+COLUMN_ROW_CREATED+") VALUES (?,?,?,NOW())";
077  private static final int[] SQL_INSERT_EXTERNAL_ID_SQL_TYPES = {SQLType.LONG.toInt(), SQLType.STRING.toInt(), SQLType.INTEGER.toInt()};
078  
079  private static final String SQL_REMOVE_EXTERNAL_IDS_BY_USER_ID = "DELETE FROM "+TABLE_USERS_EXTERNAL_IDS+" WHERE "+COLUMN_USER_ID+"=?";
080  private static final String SQL_REMOVE_USER = "DELETE FROM "+TABLE_USERS+" WHERE "+COLUMN_USER_ID+"=?";
081  private static final String SQL_REMOVE_USER_ROLES = "DELETE FROM "+TABLE_USERS_ROLES+" WHERE "+COLUMN_USER_ID+"=?";
082  private static final int[] SQL_REMOVE_USER_SQL_TYPES = {SQLType.LONG.toInt()};
083  
084  private static final String[] SQL_SELECT_COLUMNS_GET_USERS = {COLUMN_USER_ID, COLUMN_USERNAME, COLUMN_PASSWORD_HASH};
085
086  private static final String SQL_SELECT_BY_USERNAME = "SELECT "+COLUMN_COUNT+","+StringUtils.join(SQL_SELECT_COLUMNS_GET_USERS, ',')+" FROM "+TABLE_USERS+" WHERE "+COLUMN_USERNAME+"=? LIMIT 1";
087  private static final int[] SQL_SELECT_BY_USER_NAME_SQL_TYPES = {SQLType.STRING.toInt()};
088  
089  private static final String SQL_SELECT_BY_USER_ID = "SELECT "+COLUMN_COUNT+", "+StringUtils.join(SQL_SELECT_COLUMNS_GET_USERS, ',')+" FROM "+TABLE_USERS+" WHERE "+COLUMN_USER_ID+"=? LIMIT 1";
090  private static final int[] SQL_SELECT_BY_USER_ID_SQL_TYPES = {SQLType.LONG.toInt()};
091  
092  private static final String[] SQL_COLUMNS_ADD_USER = {COLUMN_USERNAME, COLUMN_PASSWORD_HASH, COLUMN_ROW_CREATED};
093  
094  /**
095   * 
096   * @param username
097   * @return the user or null if not found
098   */
099  public UserIdentity getUser(String username) {
100    LOGGER.debug("Searching user by username...");
101    UserIdentity userIdentity = extractUserIdentity(getJdbcTemplate().queryForMap(SQL_SELECT_BY_USERNAME, new Object[]{username}, SQL_SELECT_BY_USER_NAME_SQL_TYPES));
102    resolveRoles(userIdentity);
103    return userIdentity;
104  }
105  
106  /**
107   * 
108   * @param userId
109   * @return the user or null if not found
110   */
111  public UserIdentity getUser(Long userId) {
112    LOGGER.debug("Searching user by user id...");
113    UserIdentity userIdentity = extractUserIdentity(getJdbcTemplate().queryForMap(SQL_SELECT_BY_USER_ID, new Object[]{userId}, SQL_SELECT_BY_USER_ID_SQL_TYPES));
114    resolveRoles(userIdentity);
115    return userIdentity;
116  }
117  
118  /**
119   * Note: this will NOT resolve user roles
120   * 
121   * @param userIds
122   * @return list of matching user identities or null if none
123   */
124  public UserIdentityList getUsers(long[] userIds){
125    SQLSelectBuilder sql = new SQLSelectBuilder(TABLE_USERS);
126    sql.addSelectColumns(SQL_SELECT_COLUMNS_GET_USERS);
127    sql.addWhereClause(new AndClause(COLUMN_USER_ID, userIds));
128    List<Map<String, Object>> rows = getJdbcTemplate().queryForList(sql.toSQLString(), sql.getValues(), sql.getValueTypes());
129    if(rows.isEmpty()){
130      return null;
131    }
132    UserIdentityList list = new UserIdentityList();
133    for(Map<String,Object> row : rows){
134      list.addUserId(extractUserIdentity(row));
135    }
136    if(UserIdentityList.isEmpty(list)){
137      LOGGER.warn("Row count was !=0, but failed to extract any results.");
138      return null;
139    }
140    return list;
141  }
142  
143  /**
144   * Resolve and set user roles for the given user id. This is a helper method.
145   * 
146   * @param userId
147   */
148  private void resolveRoles(UserIdentity userId){
149    if(!UserIdentity.isValid(userId)){
150      LOGGER.debug("Ignored invalid user identity.");
151      return;
152    }
153    
154    for(String role : getJdbcTemplate().queryForList(SQL_GET_USER_ROLES, new Object[]{userId.getUserId()}, SQL_GET_USER_ROLES_SQL_TYPES, String.class)){
155      userId.addAuthority(UserAuthority.getGrantedAuthority(role));
156    }
157  }
158  
159  /**
160   * 
161   * @param connection
162   * @return UserIdentity with the id value set or null if none is found
163   */
164  public UserIdentity getUserId(ExternalAccountConnection connection){
165    Long userId = (Long) getJdbcTemplate().queryForMap(SQL_GET_USER_ID, new Object[]{connection.getExternalId(), connection.getServiceType().toInt()}, SQL_GET_USER_ID_SQL_TYPES).get(COLUMN_USER_ID);
166    if(userId == null){
167      return null;
168    }else{
169      return new UserIdentity(userId);
170    }
171  }
172  
173  /**
174   * 
175   * @param connection
176   * @param userId
177   * @throws IllegalArgumentException
178   */
179  public void insertExternalAccountConnection(final ExternalAccountConnection connection, final UserIdentity userId) throws IllegalArgumentException{
180    getTransactionTemplate().execute(new TransactionCallback<Void>() {
181      @Override
182      public Void doInTransaction(TransactionStatus status) {
183        Long userIdValue = userId.getUserId();
184        String externalId = connection.getExternalId();
185        UserServiceType userServiceType = connection.getServiceType();
186        
187        UserIdentity existing = getUserId(connection);
188        if(existing != null){
189          if(UserIdentity.equals(userId, existing)){
190            LOGGER.debug("Id already connected for the given user.");
191            return null;
192          }else{
193            LOGGER.debug("Cannot connect external id: "+externalId+" of "+UserServiceType.class.toString()+" : "+userServiceType.name()+" for user, id: "+userIdValue+". Already connected with another account, id: "+existing.getUserId());
194            throw new IllegalArgumentException("Failed to connect account, bad external id.");
195          }
196        }
197        getJdbcTemplate().update(SQL_INSERT_EXTERNAL_ID, new Object[]{userIdValue, externalId, userServiceType.toInt()}, SQL_INSERT_EXTERNAL_ID_SQL_TYPES);
198        return null;
199      }
200    });
201  }
202  
203  /**
204   * Remove the user and all external id connections.
205   * 
206   * @param userId
207   * @return true if user of the given id was removed
208   */
209  public boolean removeUser(UserIdentity userId){
210    Object[] ob = new Object[]{userId.getUserId()};
211    JdbcTemplate t = getJdbcTemplate();
212    if(t.update(SQL_REMOVE_USER, ob, SQL_REMOVE_USER_SQL_TYPES) != 1){
213      LOGGER.warn("Nothing was removed.");
214      return false;
215    }
216    if(t.update(SQL_REMOVE_USER_ROLES, ob, SQL_REMOVE_USER_SQL_TYPES) < 1){
217      LOGGER.warn("No roles were removed.");
218    }
219    LOGGER.debug("Removed "+t.update(SQL_REMOVE_EXTERNAL_IDS_BY_USER_ID, ob, SQL_REMOVE_USER_SQL_TYPES)+" external ids for user, id: "+ob[0]);
220    return true;
221  }
222  
223  /**
224   * On success, the passed userId will have the generated userId value set.
225   * 
226   * @param userId must have username, password and role set
227   * @return true on success, generally means invalid username, as generic database errors will throw an exception
228   * @throws IllegalArgumentException on bad userId
229   */
230  public boolean addUser(final UserIdentity userId) throws IllegalArgumentException{
231    final Collection<? extends GrantedAuthority> authorities = userId.getAuthorities();
232    if(authorities == null || authorities.isEmpty()){ // there must be at least one authority
233      throw new IllegalArgumentException("Invalid authorities.");
234    }
235    
236    return getTransactionTemplate().execute(new TransactionCallback<Boolean>() {
237      @Override
238      public Boolean doInTransaction(TransactionStatus status) {
239        String username = userId.getUsername();
240        UserIdentity check = getUser(username);
241        if(check != null){
242          LOGGER.debug("Username already in use: "+username);
243          return false;
244        }
245        
246        SimpleJdbcInsert userInsert = new SimpleJdbcInsert(getJdbcTemplate());
247        userInsert.withTableName(TABLE_USERS);
248        userInsert.setGeneratedKeyName(COLUMN_USER_ID);
249        userInsert.usingColumns(SQL_COLUMNS_ADD_USER);
250        userInsert.withoutTableColumnMetaDataAccess();
251        
252        HashMap<String, Object> parameters = new HashMap<>(SQL_COLUMNS_ADD_USER.length);
253        parameters.put(COLUMN_USERNAME, userId.getUsername());
254        parameters.put(COLUMN_PASSWORD_HASH, userId.getPassword());
255        parameters.put(COLUMN_ROW_CREATED, null);
256        Number key = userInsert.executeAndReturnKey(parameters);
257        if(key == null){
258          LOGGER.error("Failed to add new user.");
259          return false;
260        }
261        
262        userId.setUserId(key.longValue());
263        for(GrantedAuthority authority : authorities){
264          addUserRole(userId, authority);
265        }
266        
267        return true;
268      }
269    });
270  }
271  
272  /**
273   * Helper method for adding a new user role. Duplicate roles are ignored (no-op).
274   * 
275   * Note: this will NOT check for the existence of the given user.
276   * 
277   * @param userId
278   * @param authority
279   */
280  private void addUserRole(UserIdentity userId, GrantedAuthority authority){
281    getJdbcTemplate().update(SQL_ADD_USER_ROLE, new Object[]{userId.getUserId(), authority.getAuthority()}, SQL_ADD_USER_ROLE_SQL_TYPES);
282  }
283
284  /**
285   * extracts the contents of the given row
286   * 
287   * @param row
288   * @return the user or null if null or empty map was passed
289   */
290  private UserIdentity extractUserIdentity(Map<String,Object> row){
291    if(row.isEmpty()){
292      LOGGER.debug("Row contains no columns.");
293      return null;
294    }
295    
296    UserIdentity userId = new UserIdentity();
297    for(Entry<String,Object> entry : row.entrySet()){
298      switch(entry.getKey()){
299        case COLUMN_USER_ID:
300          userId.setUserId((Long) entry.getValue());
301          break;
302        case COLUMN_USERNAME:
303          userId.setUsername((String) entry.getValue());
304          break;
305        case COLUMN_PASSWORD_HASH:
306          userId.setPassword((String) entry.getValue());
307          break;
308        default:        
309          if(checkCountColumn(entry.getKey(), entry.getValue()) < 1){ // this should be count(*)
310            LOGGER.debug("Unknown column name, or no results.");
311            return null;
312          }
313          break;
314      }
315    }
316    return userId;
317  }
318  
319  /**
320   * 
321   * @param serviceTypes optional service type filter
322   * @param userId
323   * @return list of connections or null if none found
324   */
325  public ExternalAccountConnectionList getExternalAccountConnections(EnumSet<UserServiceType> serviceTypes, UserIdentity userId) {
326    SQLSelectBuilder sql = new SQLSelectBuilder(TABLE_USERS_EXTERNAL_IDS);
327    sql.addSelectColumns(SQL_GET_EXTERNAL_ACCOUNT_CONNECTIONS_COLUMNS);
328    sql.addWhereClause(new AndClause(COLUMN_USER_ID, userId.getUserId(), SQLType.LONG));
329    if(serviceTypes != null && !serviceTypes.isEmpty()){
330      sql.addWhereClause(new AndClause(COLUMN_USER_SERVICE_ID, UserServiceType.toInt(serviceTypes)));
331    }
332    
333    List<Map<String, Object>> rows = getJdbcTemplate().queryForList(sql.toSQLString(), sql.getValues(), sql.getValueTypes());
334    if(rows.isEmpty()){
335      return null;
336    }
337    ExternalAccountConnectionList list = new ExternalAccountConnectionList();
338    for(Map<String, Object> row : rows){
339      list.addConnection(extractExternalAccountConnection(row));
340    }
341    
342    if(ExternalAccountConnectionList.isEmpty(list)){
343      LOGGER.warn("Row count was !=0, but failed to extract any results.");
344      return null;
345    }else{
346      return list;
347    }
348  }
349  
350  /**
351   * 
352   * @param userId
353   * @param serviceType
354   * @return the matching external account connection or null if none found
355   */
356  public ExternalAccountConnection getExternalAccountConnection(UserIdentity userId, UserServiceType serviceType){
357    List<String> externalIds = getJdbcTemplate().queryForList(SQL_GET_EXTERNAL_ID, new Object[]{userId.getUserId(), serviceType.toInt()}, SQL_GET_EXTERNAL_ID_SQL_TYPES, String.class);
358    if(externalIds.isEmpty()){
359      LOGGER.debug("No external ids found for user, id: "+userId.getUserId());
360      return null;
361    }
362    return new ExternalAccountConnection(externalIds.get(0), serviceType);
363  }
364  
365  /**
366   * 
367   * @param row
368   * @return the connection or null if extraction was not possible
369   */
370  private ExternalAccountConnection extractExternalAccountConnection(Map<String, Object> row){
371    if(row.isEmpty()){
372      LOGGER.debug("Row contains no columns.");
373      return null;
374    }
375    
376    ExternalAccountConnection connection = new ExternalAccountConnection();
377    for(Entry<String, Object> e : row.entrySet()){
378      switch(e.getKey()){
379        case COLUMN_EXTERNAL_ID:
380          connection.setExternalId((String) e.getValue());
381          break;
382        case COLUMN_USER_SERVICE_ID:
383          connection.setServiceType(UserServiceType.fromInt((Integer)e.getValue()));
384          break;
385        default:
386          if(checkCountColumn(e.getKey(), e.getValue()) < 1){ // this should be count(*)
387            LOGGER.debug("Unknown column name, or no results.");
388            return null;
389          }
390          break;  
391      } // switch
392    } // for
393    return connection;
394  }
395
396  /**
397   * 
398   * @param userServiceType
399   * @param userId
400   * @return true if connection was removed. Note that false means that the connection did not exist, on database error an appropriate exception will be thrown.
401   */
402  public boolean deleteExternalAccountConnection(UserServiceType userServiceType, UserIdentity userId) {
403    return (getJdbcTemplate().update(SQL_DELETE_EXTERNAL_ACCOUNT_CONNECTION, new Object[]{userId.getUserId(), userServiceType.toInt()}, SQL_DELETE_EXTERNAL_ACCOUNT_CONNECTION_TYPES) > 0);
404  }
405}