/* * Copyright 2011 gitblit.com. * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. * You may obtain a copy of the License at * * http://www.apache.org/licenses/LICENSE-2.0 * * Unless required by applicable law or agreed to in writing, software * distributed under the License is distributed on an "AS IS" BASIS, * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. * See the License for the specific language governing permissions and * limitations under the License. */ package com.gitblit; import java.io.File; import java.io.IOException; import java.text.MessageFormat; import java.util.ArrayList; import java.util.Arrays; import java.util.Collection; import java.util.Collections; import java.util.HashSet; import java.util.List; import java.util.Locale; import java.util.Map; import java.util.Set; import java.util.concurrent.ConcurrentHashMap; import org.eclipse.jgit.lib.StoredConfig; import org.eclipse.jgit.storage.file.FileBasedConfig; import org.eclipse.jgit.util.FS; import org.slf4j.Logger; import org.slf4j.LoggerFactory; import com.gitblit.Constants.AccessPermission; import com.gitblit.Constants.AccountType; import com.gitblit.Constants.Role; import com.gitblit.Constants.Transport; import com.gitblit.manager.IRuntimeManager; import com.gitblit.models.TeamModel; import com.gitblit.models.UserModel; import com.gitblit.models.UserRepositoryPreferences; import com.gitblit.utils.ArrayUtils; import com.gitblit.utils.DeepCopier; import com.gitblit.utils.StringUtils; /** * ConfigUserService is Gitblit's default user service implementation since * version 0.8.0. * * Users and their repository memberships are stored in a git-style config file * which is cached and dynamically reloaded when modified. This file is * plain-text, human-readable, and may be edited with a text editor. * * Additionally, this format allows for expansion of the user model without * bringing in the complexity of a database. * * @author James Moger * */ public class ConfigUserService implements IUserService { private static final String TEAM = "team"; private static final String USER = "user"; private static final String PASSWORD = "password"; private static final String DISPLAYNAME = "displayName"; private static final String EMAILADDRESS = "emailAddress"; private static final String ORGANIZATIONALUNIT = "organizationalUnit"; private static final String ORGANIZATION = "organization"; private static final String LOCALITY = "locality"; private static final String STATEPROVINCE = "stateProvince"; private static final String COUNTRYCODE = "countryCode"; private static final String COOKIE = "cookie"; private static final String REPOSITORY = "repository"; private static final String ROLE = "role"; private static final String MAILINGLIST = "mailingList"; private static final String PRERECEIVE = "preReceiveScript"; private static final String POSTRECEIVE = "postReceiveScript"; private static final String STARRED = "starred"; private static final String LOCALE = "locale"; private static final String EMAILONMYTICKETCHANGES = "emailMeOnMyTicketChanges"; private static final String TRANSPORT = "transport"; private static final String ACCOUNTTYPE = "accountType"; private static final String DISABLED = "disabled"; private final File realmFile; private final Logger logger = LoggerFactory.getLogger(ConfigUserService.class); private final Map users = new ConcurrentHashMap(); private final Map cookies = new ConcurrentHashMap(); private final Map teams = new ConcurrentHashMap(); private volatile long lastModified; private volatile boolean forceReload; public ConfigUserService(File realmFile) { this.realmFile = realmFile; } /** * Setup the user service. * * @param runtimeManager * @since 1.4.0 */ @Override public void setup(IRuntimeManager runtimeManager) { } /** * Returns the cookie value for the specified user. * * @param model * @return cookie value */ @Override public synchronized String getCookie(UserModel model) { if (!StringUtils.isEmpty(model.cookie)) { return model.cookie; } UserModel storedModel = getUserModel(model.username); if (storedModel == null) { return null; } return storedModel.cookie; } /** * Gets the user object for the specified cookie. * * @param cookie * @return a user object or null */ @Override public synchronized UserModel getUserModel(char[] cookie) { String hash = new String(cookie); if (StringUtils.isEmpty(hash)) { return null; } read(); UserModel model = null; if (cookies.containsKey(hash)) { model = cookies.get(hash); } if (model != null) { // clone the model, otherwise all changes to this object are // live and unpersisted model = DeepCopier.copy(model); } return model; } /** * Retrieve the user object for the specified username. * * @param username * @return a user object or null */ @Override public synchronized UserModel getUserModel(String username) { read(); UserModel model = users.get(username.toLowerCase()); if (model != null) { // clone the model, otherwise all changes to this object are // live and unpersisted model = DeepCopier.copy(model); } return model; } /** * Updates/writes a complete user object. * * @param model * @return true if update is successful */ @Override public synchronized boolean updateUserModel(UserModel model) { return updateUserModel(model.username, model); } /** * Updates/writes all specified user objects. * * @param models a list of user models * @return true if update is successful * @since 1.2.0 */ @Override public synchronized boolean updateUserModels(Collection models) { try { read(); for (UserModel model : models) { UserModel originalUser = users.remove(model.username.toLowerCase()); users.put(model.username.toLowerCase(), model); // null check on "final" teams because JSON-sourced UserModel // can have a null teams object if (model.teams != null) { Set userTeams = new HashSet(); for (TeamModel team : model.teams) { TeamModel t = teams.get(team.name.toLowerCase()); if (t == null) { // new team t = team; teams.put(team.name.toLowerCase(), t); } // do not clobber existing team definition // maybe because this is a federated user t.addUser(model.username); userTeams.add(t); } // replace Team-Models in users by new ones. model.teams.clear(); model.teams.addAll(userTeams); // check for implicit team removal if (originalUser != null) { for (TeamModel team : originalUser.teams) { if (!model.isTeamMember(team.name)) { team.removeUser(model.username); } } } } } write(); return true; } catch (Throwable t) { logger.error(MessageFormat.format("Failed to update user {0} models!", models.size()), t); } return false; } /** * Updates/writes and replaces a complete user object keyed by username. * This method allows for renaming a user. * * @param username * the old username * @param model * the user object to use for username * @return true if update is successful */ @Override public synchronized boolean updateUserModel(String username, UserModel model) { UserModel originalUser = null; try { if (!model.isLocalAccount()) { // do not persist password model.password = Constants.EXTERNAL_ACCOUNT; } read(); originalUser = users.remove(username.toLowerCase()); if (originalUser != null) { cookies.remove(originalUser.cookie); } users.put(model.username.toLowerCase(), model); // null check on "final" teams because JSON-sourced UserModel // can have a null teams object if (model.teams != null) { for (TeamModel team : model.teams) { TeamModel t = teams.get(team.name.toLowerCase()); if (t == null) { // new team team.addUser(username); teams.put(team.name.toLowerCase(), team); } else { // do not clobber existing team definition // maybe because this is a federated user t.removeUser(username); t.addUser(model.username); } } // check for implicit team removal if (originalUser != null) { for (TeamModel team : originalUser.teams) { if (!model.isTeamMember(team.name)) { team.removeUser(username); } } } } write(); return true; } catch (Throwable t) { if (originalUser != null) { // restore original user users.put(originalUser.username.toLowerCase(), originalUser); } else { // drop attempted add users.remove(model.username.toLowerCase()); } logger.error(MessageFormat.format("Failed to update user model {0}!", model.username), t); } return false; } /** * Deletes the user object from the user service. * * @param model * @return true if successful */ @Override public synchronized boolean deleteUserModel(UserModel model) { return deleteUser(model.username); } /** * Delete the user object with the specified username * * @param username * @return true if successful */ @Override public synchronized boolean deleteUser(String username) { try { // Read realm file read(); UserModel model = users.remove(username.toLowerCase()); if (model == null) { // user does not exist return false; } // remove user from team for (TeamModel team : model.teams) { TeamModel t = teams.get(team.name); if (t == null) { // new team team.removeUser(username); teams.put(team.name.toLowerCase(), team); } else { // existing team t.removeUser(username); } } write(); return true; } catch (Throwable t) { logger.error(MessageFormat.format("Failed to delete user {0}!", username), t); } return false; } /** * Returns the list of all teams available to the login service. * * @return list of all teams * @since 0.8.0 */ @Override public synchronized List getAllTeamNames() { read(); List list = new ArrayList(teams.keySet()); Collections.sort(list); return list; } /** * Returns the list of all teams available to the login service. * * @return list of all teams * @since 0.8.0 */ @Override public synchronized List getAllTeams() { read(); List list = new ArrayList(teams.values()); list = DeepCopier.copy(list); Collections.sort(list); return list; } /** * Returns the list of all users who are allowed to bypass the access * restriction placed on the specified repository. * * @param role * the repository name * @return list of all usernames that can bypass the access restriction */ @Override public synchronized List getTeamNamesForRepositoryRole(String role) { List list = new ArrayList(); try { read(); for (Map.Entry entry : teams.entrySet()) { TeamModel model = entry.getValue(); if (model.hasRepositoryPermission(role)) { list.add(model.name); } } } catch (Throwable t) { logger.error(MessageFormat.format("Failed to get teamnames for role {0}!", role), t); } Collections.sort(list); return list; } /** * Retrieve the team object for the specified team name. * * @param teamname * @return a team object or null * @since 0.8.0 */ @Override public synchronized TeamModel getTeamModel(String teamname) { read(); TeamModel model = teams.get(teamname.toLowerCase()); if (model != null) { // clone the model, otherwise all changes to this object are // live and unpersisted model = DeepCopier.copy(model); } return model; } /** * Updates/writes a complete team object. * * @param model * @return true if update is successful * @since 0.8.0 */ @Override public synchronized boolean updateTeamModel(TeamModel model) { return updateTeamModel(model.name, model); } /** * Updates/writes all specified team objects. * * @param models a list of team models * @return true if update is successful * @since 1.2.0 */ @Override public synchronized boolean updateTeamModels(Collection models) { try { read(); for (TeamModel team : models) { teams.put(team.name.toLowerCase(), team); } write(); return true; } catch (Throwable t) { logger.error(MessageFormat.format("Failed to update team {0} models!", models.size()), t); } return false; } /** * Updates/writes and replaces a complete team object keyed by teamname. * This method allows for renaming a team. * * @param teamname * the old teamname * @param model * the team object to use for teamname * @return true if update is successful * @since 0.8.0 */ @Override public synchronized boolean updateTeamModel(String teamname, TeamModel model) { TeamModel original = null; try { read(); original = teams.remove(teamname.toLowerCase()); teams.put(model.name.toLowerCase(), model); write(); return true; } catch (Throwable t) { if (original != null) { // restore original team teams.put(original.name.toLowerCase(), original); } else { // drop attempted add teams.remove(model.name.toLowerCase()); } logger.error(MessageFormat.format("Failed to update team model {0}!", model.name), t); } return false; } /** * Deletes the team object from the user service. * * @param model * @return true if successful * @since 0.8.0 */ @Override public synchronized boolean deleteTeamModel(TeamModel model) { return deleteTeam(model.name); } /** * Delete the team object with the specified teamname * * @param teamname * @return true if successful * @since 0.8.0 */ @Override public synchronized boolean deleteTeam(String teamname) { try { // Read realm file read(); teams.remove(teamname.toLowerCase()); write(); return true; } catch (Throwable t) { logger.error(MessageFormat.format("Failed to delete team {0}!", teamname), t); } return false; } /** * Returns the list of all users available to the login service. * * @return list of all usernames */ @Override public synchronized List getAllUsernames() { read(); List list = new ArrayList(users.keySet()); Collections.sort(list); return list; } /** * Returns the list of all users available to the login service. * * @return list of all usernames */ @Override public synchronized List getAllUsers() { read(); List list = new ArrayList(users.values()); list = DeepCopier.copy(list); Collections.sort(list); return list; } /** * Returns the list of all users who are allowed to bypass the access * restriction placed on the specified repository. * * @param role * the repository name * @return list of all usernames that can bypass the access restriction */ @Override public synchronized List getUsernamesForRepositoryRole(String role) { List list = new ArrayList(); try { read(); for (Map.Entry entry : users.entrySet()) { UserModel model = entry.getValue(); if (model.hasRepositoryPermission(role)) { list.add(model.username); } } } catch (Throwable t) { logger.error(MessageFormat.format("Failed to get usernames for role {0}!", role), t); } Collections.sort(list); return list; } /** * Renames a repository role. * * @param oldRole * @param newRole * @return true if successful */ @Override public synchronized boolean renameRepositoryRole(String oldRole, String newRole) { try { read(); // identify users which require role rename for (UserModel model : users.values()) { if (model.hasRepositoryPermission(oldRole)) { AccessPermission permission = model.removeRepositoryPermission(oldRole); model.setRepositoryPermission(newRole, permission); } } // identify teams which require role rename for (TeamModel model : teams.values()) { if (model.hasRepositoryPermission(oldRole)) { AccessPermission permission = model.removeRepositoryPermission(oldRole); model.setRepositoryPermission(newRole, permission); } } // persist changes write(); return true; } catch (Throwable t) { logger.error( MessageFormat.format("Failed to rename role {0} to {1}!", oldRole, newRole), t); } return false; } /** * Removes a repository role from all users. * * @param role * @return true if successful */ @Override public synchronized boolean deleteRepositoryRole(String role) { try { read(); // identify users which require role rename for (UserModel user : users.values()) { user.removeRepositoryPermission(role); } // identify teams which require role rename for (TeamModel team : teams.values()) { team.removeRepositoryPermission(role); } // persist changes write(); return true; } catch (Throwable t) { logger.error(MessageFormat.format("Failed to delete role {0}!", role), t); } return false; } /** * Writes the properties file. * * @throws IOException */ private synchronized void write() throws IOException { // Write a temporary copy of the users file File realmFileCopy = new File(realmFile.getAbsolutePath() + ".tmp"); StoredConfig config = new FileBasedConfig(realmFileCopy, FS.detect()); // write users for (UserModel model : users.values()) { if (!StringUtils.isEmpty(model.password)) { config.setString(USER, model.username, PASSWORD, model.password); } if (!StringUtils.isEmpty(model.cookie)) { config.setString(USER, model.username, COOKIE, model.cookie); } if (!StringUtils.isEmpty(model.displayName)) { config.setString(USER, model.username, DISPLAYNAME, model.displayName); } if (!StringUtils.isEmpty(model.emailAddress)) { config.setString(USER, model.username, EMAILADDRESS, model.emailAddress); } if (model.accountType != null) { config.setString(USER, model.username, ACCOUNTTYPE, model.accountType.name()); } if (!StringUtils.isEmpty(model.organizationalUnit)) { config.setString(USER, model.username, ORGANIZATIONALUNIT, model.organizationalUnit); } if (!StringUtils.isEmpty(model.organization)) { config.setString(USER, model.username, ORGANIZATION, model.organization); } if (!StringUtils.isEmpty(model.locality)) { config.setString(USER, model.username, LOCALITY, model.locality); } if (!StringUtils.isEmpty(model.stateProvince)) { config.setString(USER, model.username, STATEPROVINCE, model.stateProvince); } if (!StringUtils.isEmpty(model.countryCode)) { config.setString(USER, model.username, COUNTRYCODE, model.countryCode); } if (model.disabled) { config.setBoolean(USER, model.username, DISABLED, true); } if (model.getPreferences() != null) { Locale locale = model.getPreferences().getLocale(); if (locale != null) { String val; if (StringUtils.isEmpty(locale.getCountry())) { val = locale.getLanguage(); } else { val = locale.getLanguage() + "_" + locale.getCountry(); } config.setString(USER, model.username, LOCALE, val); } config.setBoolean(USER, model.username, EMAILONMYTICKETCHANGES, model.getPreferences().isEmailMeOnMyTicketChanges()); if (model.getPreferences().getTransport() != null) { config.setString(USER, model.username, TRANSPORT, model.getPreferences().getTransport().name()); } } // user roles List roles = new ArrayList(); if (model.canAdmin) { roles.add(Role.ADMIN.getRole()); } if (model.canFork) { roles.add(Role.FORK.getRole()); } if (model.canCreate) { roles.add(Role.CREATE.getRole()); } if (model.excludeFromFederation) { roles.add(Role.NOT_FEDERATED.getRole()); } if (roles.size() == 0) { // we do this to ensure that user record with no password // is written. otherwise, StoredConfig optimizes that account // away. :( roles.add(Role.NONE.getRole()); } config.setStringList(USER, model.username, ROLE, roles); // discrete repository permissions if (model.permissions != null && !model.canAdmin) { List permissions = new ArrayList(); for (Map.Entry entry : model.permissions.entrySet()) { if (entry.getValue().exceeds(AccessPermission.NONE)) { permissions.add(entry.getValue().asRole(entry.getKey())); } } config.setStringList(USER, model.username, REPOSITORY, permissions); } // user preferences if (model.getPreferences() != null) { List starred = model.getPreferences().getStarredRepositories(); if (starred.size() > 0) { config.setStringList(USER, model.username, STARRED, starred); } } } // write teams for (TeamModel model : teams.values()) { // team roles List roles = new ArrayList(); if (model.canAdmin) { roles.add(Role.ADMIN.getRole()); } if (model.canFork) { roles.add(Role.FORK.getRole()); } if (model.canCreate) { roles.add(Role.CREATE.getRole()); } if (roles.size() == 0) { // we do this to ensure that team record is written. // Otherwise, StoredConfig might optimizes that record away. roles.add(Role.NONE.getRole()); } config.setStringList(TEAM, model.name, ROLE, roles); if (model.accountType != null) { config.setString(TEAM, model.name, ACCOUNTTYPE, model.accountType.name()); } if (!model.canAdmin) { // write team permission for non-admin teams if (model.permissions == null) { // null check on "final" repositories because JSON-sourced TeamModel // can have a null repositories object if (!ArrayUtils.isEmpty(model.repositories)) { config.setStringList(TEAM, model.name, REPOSITORY, new ArrayList( model.repositories)); } } else { // discrete repository permissions List permissions = new ArrayList(); for (Map.Entry entry : model.permissions.entrySet()) { if (entry.getValue().exceeds(AccessPermission.NONE)) { // code:repository (e.g. RW+:~james/myrepo.git permissions.add(entry.getValue().asRole(entry.getKey())); } } config.setStringList(TEAM, model.name, REPOSITORY, permissions); } } // null check on "final" users because JSON-sourced TeamModel // can have a null users object if (!ArrayUtils.isEmpty(model.users)) { config.setStringList(TEAM, model.name, USER, new ArrayList(model.users)); } // null check on "final" mailing lists because JSON-sourced // TeamModel can have a null users object if (!ArrayUtils.isEmpty(model.mailingLists)) { config.setStringList(TEAM, model.name, MAILINGLIST, new ArrayList( model.mailingLists)); } // null check on "final" preReceiveScripts because JSON-sourced // TeamModel can have a null preReceiveScripts object if (!ArrayUtils.isEmpty(model.preReceiveScripts)) { config.setStringList(TEAM, model.name, PRERECEIVE, model.preReceiveScripts); } // null check on "final" postReceiveScripts because JSON-sourced // TeamModel can have a null postReceiveScripts object if (!ArrayUtils.isEmpty(model.postReceiveScripts)) { config.setStringList(TEAM, model.name, POSTRECEIVE, model.postReceiveScripts); } } config.save(); // manually set the forceReload flag because not all JVMs support real // millisecond resolution of lastModified. (issue-55) forceReload = true; // If the write is successful, delete the current file and rename // the temporary copy to the original filename. if (realmFileCopy.exists() && realmFileCopy.length() > 0) { if (realmFile.exists()) { if (!realmFile.delete()) { throw new IOException(MessageFormat.format("Failed to delete {0}!", realmFile.getAbsolutePath())); } } if (!realmFileCopy.renameTo(realmFile)) { throw new IOException(MessageFormat.format("Failed to rename {0} to {1}!", realmFileCopy.getAbsolutePath(), realmFile.getAbsolutePath())); } } else { throw new IOException(MessageFormat.format("Failed to save {0}!", realmFileCopy.getAbsolutePath())); } } /** * Reads the realm file and rebuilds the in-memory lookup tables. */ protected synchronized void read() { if (realmFile.exists() && (forceReload || (realmFile.lastModified() != lastModified))) { forceReload = false; lastModified = realmFile.lastModified(); users.clear(); cookies.clear(); teams.clear(); try { StoredConfig config = new FileBasedConfig(realmFile, FS.detect()); config.load(); Set usernames = config.getSubsections(USER); for (String username : usernames) { UserModel user = new UserModel(username.toLowerCase()); user.password = config.getString(USER, username, PASSWORD); user.displayName = config.getString(USER, username, DISPLAYNAME); user.emailAddress = config.getString(USER, username, EMAILADDRESS); user.accountType = AccountType.fromString(config.getString(USER, username, ACCOUNTTYPE)); if (Constants.EXTERNAL_ACCOUNT.equals(user.password) && user.accountType.isLocal()) { user.accountType = AccountType.EXTERNAL; } user.disabled = config.getBoolean(USER, username, DISABLED, false); user.organizationalUnit = config.getString(USER, username, ORGANIZATIONALUNIT); user.organization = config.getString(USER, username, ORGANIZATION); user.locality = config.getString(USER, username, LOCALITY); user.stateProvince = config.getString(USER, username, STATEPROVINCE); user.countryCode = config.getString(USER, username, COUNTRYCODE); user.cookie = config.getString(USER, username, COOKIE); if (StringUtils.isEmpty(user.cookie) && !StringUtils.isEmpty(user.password)) { user.cookie = StringUtils.getSHA1(user.username + user.password); } // preferences user.getPreferences().setLocale(config.getString(USER, username, LOCALE)); user.getPreferences().setEmailMeOnMyTicketChanges(config.getBoolean(USER, username, EMAILONMYTICKETCHANGES, true)); user.getPreferences().setTransport(Transport.fromString(config.getString(USER, username, TRANSPORT))); // user roles Set roles = new HashSet(Arrays.asList(config.getStringList( USER, username, ROLE))); user.canAdmin = roles.contains(Role.ADMIN.getRole()); user.canFork = roles.contains(Role.FORK.getRole()); user.canCreate = roles.contains(Role.CREATE.getRole()); user.excludeFromFederation = roles.contains(Role.NOT_FEDERATED.getRole()); // repository memberships if (!user.canAdmin) { // non-admin, read permissions Set repositories = new HashSet(Arrays.asList(config .getStringList(USER, username, REPOSITORY))); for (String repository : repositories) { user.addRepositoryPermission(repository); } } // starred repositories Set starred = new HashSet(Arrays.asList(config .getStringList(USER, username, STARRED))); for (String repository : starred) { UserRepositoryPreferences prefs = user.getPreferences().getRepositoryPreferences(repository); prefs.starred = true; } // update cache users.put(user.username, user); if (!StringUtils.isEmpty(user.cookie)) { cookies.put(user.cookie, user); } } // load the teams Set teamnames = config.getSubsections(TEAM); for (String teamname : teamnames) { TeamModel team = new TeamModel(teamname); Set roles = new HashSet(Arrays.asList(config.getStringList( TEAM, teamname, ROLE))); team.canAdmin = roles.contains(Role.ADMIN.getRole()); team.canFork = roles.contains(Role.FORK.getRole()); team.canCreate = roles.contains(Role.CREATE.getRole()); team.accountType = AccountType.fromString(config.getString(TEAM, teamname, ACCOUNTTYPE)); if (!team.canAdmin) { // non-admin team, read permissions team.addRepositoryPermissions(Arrays.asList(config.getStringList(TEAM, teamname, REPOSITORY))); } team.addUsers(Arrays.asList(config.getStringList(TEAM, teamname, USER))); team.addMailingLists(Arrays.asList(config.getStringList(TEAM, teamname, MAILINGLIST))); team.preReceiveScripts.addAll(Arrays.asList(config.getStringList(TEAM, teamname, PRERECEIVE))); team.postReceiveScripts.addAll(Arrays.asList(config.getStringList(TEAM, teamname, POSTRECEIVE))); teams.put(team.name.toLowerCase(), team); // set the teams on the users for (String user : team.users) { UserModel model = users.get(user); if (model != null) { model.teams.add(team); } } } } catch (Exception e) { logger.error(MessageFormat.format("Failed to read {0}", realmFile), e); } } } protected long lastModified() { return lastModified; } @Override public String toString() { return getClass().getSimpleName() + "(" + realmFile.getAbsolutePath() + ")"; } }