Skip to content

Commit fef9250

Browse files
authored
Deprecate UserIdMapper and just use an HMAC (#10926)
* Deprecate `UserIdMapper` and just use an HMAC * Run `UserIdMapper.migrate` when first required, in case JCasC initializes users early * Enhanced logging for an error case #10926 (comment) * More compatible migration from ancient `$JENKINS_HOME` highlighted by `UserIdMigratorTest/migrateMultipleUsers/users/foo$002fbar/config.xml`
1 parent 521ecfd commit fef9250

File tree

40 files changed

+191
-1078
lines changed

40 files changed

+191
-1078
lines changed

core/src/main/java/hudson/model/User.java

Lines changed: 123 additions & 45 deletions
Original file line numberDiff line numberDiff line change
@@ -52,12 +52,15 @@
5252
import jakarta.servlet.http.HttpServletResponse;
5353
import java.io.File;
5454
import java.io.IOException;
55+
import java.nio.file.Files;
56+
import java.nio.file.StandardCopyOption;
5557
import java.util.ArrayList;
5658
import java.util.Arrays;
5759
import java.util.Collection;
5860
import java.util.Collections;
5961
import java.util.HashSet;
6062
import java.util.List;
63+
import java.util.Locale;
6164
import java.util.Map;
6265
import java.util.Objects;
6366
import java.util.Set;
@@ -67,12 +70,14 @@
6770
import java.util.function.Predicate;
6871
import java.util.logging.Level;
6972
import java.util.logging.Logger;
73+
import java.util.regex.Pattern;
7074
import jenkins.model.IdStrategy;
7175
import jenkins.model.Jenkins;
7276
import jenkins.model.Loadable;
7377
import jenkins.model.ModelObjectWithContextMenu;
7478
import jenkins.scm.RunWithSCM;
7579
import jenkins.search.SearchGroup;
80+
import jenkins.security.HMACConfidentialKey;
7681
import jenkins.security.ImpersonatingUserDetailsService2;
7782
import jenkins.security.LastGrantedAuthoritiesProperty;
7883
import jenkins.security.UserDetailsCache;
@@ -174,7 +179,7 @@ public class User extends AbstractModelObject implements AccessControlled, Descr
174179

175180
@SuppressFBWarnings(value = "SS_SHOULD_BE_STATIC", justification = "Reserved for future use")
176181
private final int version = 10; // Not currently used, but it may be helpful in the future to store a version.
177-
private String id;
182+
String id;
178183
private volatile String fullName;
179184
private volatile String description;
180185

@@ -185,6 +190,8 @@ public class User extends AbstractModelObject implements AccessControlled, Descr
185190
XSTREAM.alias("user", User.class);
186191
}
187192

193+
private User() {}
194+
188195
private User(String id, String fullName) {
189196
this.id = id;
190197
this.fullName = fullName;
@@ -199,6 +206,10 @@ public void load() {
199206
private void load(String userId) {
200207
clearExistingProperties();
201208
loadFromUserConfigFile(userId);
209+
fixUpAfterLoad();
210+
}
211+
212+
private void fixUpAfterLoad() {
202213
removeNullsThatFailedToLoad();
203214
allocateDefaultPropertyInstancesAsNeeded();
204215
setUserToProperties();
@@ -225,9 +236,10 @@ private void removeNullsThatFailedToLoad() {
225236
}
226237

227238
private void loadFromUserConfigFile(String userId) {
239+
AllUsers.getInstance().migrateUserIdMapper();
228240
XmlFile config = getConfigFile();
229241
try {
230-
if (config != null && config.exists()) {
242+
if (config.exists()) {
231243
config.unmarshal(this);
232244
this.id = userId;
233245
}
@@ -241,8 +253,7 @@ private void clearExistingProperties() {
241253
}
242254

243255
private XmlFile getConfigFile() {
244-
File existingUserFolder = getExistingUserFolder();
245-
return existingUserFolder == null ? null : new XmlFile(XSTREAM, new File(existingUserFolder, CONFIG_XML));
256+
return new XmlFile(XSTREAM, new File(getUserFolderFor(id), CONFIG_XML));
246257
}
247258

248259
/**
@@ -571,10 +582,10 @@ public void doSubmitDescription(StaplerRequest2 req, StaplerResponse2 rsp) throw
571582
*/
572583
private static @Nullable User getOrCreateById(@NonNull String id, @NonNull String fullName, boolean create) {
573584
User u = AllUsers.get(id);
574-
if (u == null && (create || UserIdMapper.getInstance().isMapped(id))) {
585+
if (u == null && create) {
575586
u = new User(id, fullName);
576587
AllUsers.put(id, u);
577-
if (!id.equals(fullName) && !UserIdMapper.getInstance().isMapped(id)) {
588+
if (!id.equals(fullName)) {
578589
try {
579590
u.save();
580591
} catch (IOException x) {
@@ -691,7 +702,6 @@ public void doSubmitDescription(StaplerRequest2 req, StaplerResponse2 rsp) throw
691702
*/
692703
@Restricted(Beta.class)
693704
public static void reload() throws IOException {
694-
UserIdMapper.getInstance().reload();
695705
AllUsers.reload();
696706
}
697707

@@ -708,6 +718,32 @@ public static void rekey() {
708718
or greater issues in the realm change, could affect currently logged
709719
in users and even the user making the change. */
710720
try {
721+
var subdirectories = getRootDir().listFiles();
722+
if (subdirectories != null) {
723+
for (var oldDirectory : subdirectories) {
724+
var dirName = oldDirectory.getName();
725+
if (!HASHED_DIRNAMES.matcher(dirName).matches()) {
726+
continue;
727+
}
728+
var xml = new XmlFile(XSTREAM, new File(oldDirectory, CONFIG_XML));
729+
if (!xml.exists()) {
730+
continue;
731+
}
732+
try {
733+
var user = (User) xml.read();
734+
if (user.id == null) {
735+
continue;
736+
}
737+
var newDirectory = getUserFolderFor(user.id);
738+
if (!oldDirectory.equals(newDirectory)) {
739+
Files.move(oldDirectory.toPath(), newDirectory.toPath(), StandardCopyOption.REPLACE_EXISTING);
740+
LOGGER.info(() -> "migrated " + oldDirectory + " to " + newDirectory);
741+
}
742+
} catch (Exception x) {
743+
LOGGER.log(Level.WARNING, "failed to migrate " + xml, x);
744+
}
745+
}
746+
}
711747
reload();
712748
} catch (IOException e) {
713749
LOGGER.log(Level.SEVERE, "Failed to perform rekey operation.", e);
@@ -777,17 +813,9 @@ public static void clear() {
777813
if (ExtensionList.lookup(AllUsers.class).isEmpty()) {
778814
return;
779815
}
780-
UserIdMapper.getInstance().clear();
781816
AllUsers.clear();
782817
}
783818

784-
private static File getConfigFileFor(String id) {
785-
return new File(getUserFolderFor(id), "config.xml");
786-
}
787-
788-
private static File getUserFolderFor(String id) {
789-
return new File(getRootDir(), idStrategy().filenameOf(id));
790-
}
791819
/**
792820
* Returns the folder that store all the user information.
793821
* Useful for plugins to save a user-specific file aside the config.xml.
@@ -799,11 +827,8 @@ private static File getUserFolderFor(String id) {
799827
*/
800828

801829
public @CheckForNull File getUserFolder() {
802-
return getExistingUserFolder();
803-
}
804-
805-
private @CheckForNull File getExistingUserFolder() {
806-
return UserIdMapper.getInstance().getDirectory(id);
830+
var d = getUserFolderFor(id);
831+
return d.isDirectory() ? d : null;
807832
}
808833

809834
/**
@@ -813,6 +838,21 @@ static File getRootDir() {
813838
return new File(Jenkins.get().getRootDir(), "users");
814839
}
815840

841+
private static final int PREFIX_MAX = 14;
842+
private static final Pattern DISALLOWED_PREFIX_CHARS = Pattern.compile("[^A-Za-z0-9]");
843+
static final Pattern HASHED_DIRNAMES = Pattern.compile("[a-z0-9]{0," + PREFIX_MAX + "}_[a-f0-9]{64}");
844+
private static final HMACConfidentialKey DIRNAMES = new HMACConfidentialKey(User.class, "DIRNAMES");
845+
846+
private static String getUserFolderNameFor(String id) {
847+
var fullPrefix = DISALLOWED_PREFIX_CHARS.matcher(id).replaceAll("").toLowerCase(Locale.ROOT);
848+
return (fullPrefix.length() > PREFIX_MAX ? fullPrefix.substring(0, PREFIX_MAX) : fullPrefix) + '_' + DIRNAMES.mac(idStrategy().keyFor(id));
849+
}
850+
851+
@SuppressFBWarnings(value = "PATH_TRAVERSAL_IN", justification = "sanitized")
852+
static File getUserFolderFor(String id) {
853+
return new File(getRootDir(), getUserFolderNameFor(id));
854+
}
855+
816856
/**
817857
* Is the ID allowed? Some are prohibited for security reasons. See SECURITY-166.
818858
* <p>
@@ -852,39 +892,23 @@ public synchronized void save() throws IOException {
852892
if (BulkChange.contains(this)) {
853893
return;
854894
}
855-
XmlFile xmlFile = new XmlFile(XSTREAM, constructUserConfigFile());
895+
XmlFile xmlFile = getConfigFile();
856896
xmlFile.write(this);
857897
SaveableListener.fireOnChange(this, xmlFile);
858898
}
859899

860-
private File constructUserConfigFile() throws IOException {
861-
return new File(putUserFolderIfAbsent(), CONFIG_XML);
862-
}
863-
864-
private File putUserFolderIfAbsent() throws IOException {
865-
return UserIdMapper.getInstance().putIfAbsent(id, true);
866-
}
867-
868900
/**
869901
* Deletes the data directory and removes this user from Hudson.
870902
*
871903
* @throws IOException if we fail to delete.
872904
*/
873905
public void delete() throws IOException {
874906
String idKey = idStrategy().keyFor(id);
875-
File existingUserFolder = getExistingUserFolder();
876-
UserIdMapper.getInstance().remove(id);
877907
AllUsers.remove(id);
878-
deleteExistingUserFolder(existingUserFolder);
908+
Util.deleteRecursive(getUserFolderFor(id));
879909
UserDetailsCache.get().invalidate(idKey);
880910
}
881911

882-
private void deleteExistingUserFolder(File existingUserFolder) throws IOException {
883-
if (existingUserFolder != null && existingUserFolder.exists()) {
884-
Util.deleteRecursive(existingUserFolder);
885-
}
886-
}
887-
888912
/**
889913
* Exposed remote API.
890914
*/
@@ -947,7 +971,7 @@ public ACL getACL() {
947971
public boolean canDelete() {
948972
final IdStrategy strategy = idStrategy();
949973
return hasPermission(Jenkins.ADMINISTER) && !strategy.equals(id, Jenkins.getAuthentication2().getName())
950-
&& UserIdMapper.getInstance().isMapped(id);
974+
&& getUserFolder() != null;
951975
}
952976

953977
/**
@@ -1074,14 +1098,68 @@ private Object readResolve() {
10741098
@Restricted(NoExternalUse.class)
10751099
public static final class AllUsers {
10761100

1101+
private boolean migratedUserIdMapper;
10771102
private final ConcurrentMap<String, User> byName = new ConcurrentHashMap<>();
10781103

1104+
@SuppressWarnings("deprecation")
1105+
synchronized void migrateUserIdMapper() {
1106+
if (!migratedUserIdMapper) {
1107+
try {
1108+
UserIdMapper.migrate();
1109+
} catch (IOException x) {
1110+
LOGGER.log(Level.WARNING, null, x);
1111+
}
1112+
migratedUserIdMapper = true;
1113+
}
1114+
}
1115+
10791116
@Initializer(after = InitMilestone.JOB_CONFIG_ADAPTED)
1080-
public static void scanAll() {
1081-
for (String userId : UserIdMapper.getInstance().getConvertedUserIds()) {
1082-
User user = new User(userId, userId);
1083-
getInstance().byName.putIfAbsent(idStrategy().keyFor(userId), user);
1117+
public static void scanAll() throws IOException {
1118+
DIRNAMES.createMac(); // force the key to be saved during startup
1119+
var instance = getInstance();
1120+
instance.migrateUserIdMapper();
1121+
var subdirectories = getRootDir().listFiles();
1122+
if (subdirectories == null) {
1123+
return;
1124+
}
1125+
var byName = instance.byName;
1126+
var idStrategy = idStrategy();
1127+
for (var dir : subdirectories) {
1128+
var dirName = dir.getName();
1129+
if (!HASHED_DIRNAMES.matcher(dirName).matches()) {
1130+
LOGGER.fine(() -> "ignoring unrecognized dir " + dir);
1131+
continue;
1132+
}
1133+
var xml = new XmlFile(XSTREAM, new File(dir, CONFIG_XML));
1134+
if (!xml.exists()) {
1135+
LOGGER.fine(() -> "ignoring dir " + dir + " with no " + CONFIG_XML);
1136+
continue;
1137+
}
1138+
var user = new User();
1139+
try {
1140+
xml.unmarshal(user);
1141+
} catch (Exception x) {
1142+
LOGGER.log(Level.WARNING, "failed to load " + xml, x);
1143+
continue;
1144+
}
1145+
if (user.id == null) {
1146+
LOGGER.warning(() -> "ignoring " + xml + " with no <id>");
1147+
continue;
1148+
}
1149+
var expectedFolderName = getUserFolderNameFor(user.id);
1150+
if (!dirName.equals(expectedFolderName)) {
1151+
LOGGER.warning(() -> "ignoring " + xml + " with <id> " + user.id + " expected to be in " + expectedFolderName);
1152+
continue;
1153+
}
1154+
user.fixUpAfterLoad();
1155+
var old = byName.put(idStrategy.keyFor(user.id), user);
1156+
if (old != null) {
1157+
LOGGER.warning(() -> "entry for " + user.id + " in " + dir + " duplicates one seen earlier for " + old.id);
1158+
} else {
1159+
LOGGER.fine(() -> "successfully loaded " + user.id + " from " + xml);
1160+
}
10841161
}
1162+
LOGGER.fine(() -> "loaded " + byName.size() + " entries");
10851163
}
10861164

10871165
/**
@@ -1094,7 +1172,7 @@ private static AllUsers getInstance() {
10941172
return ExtensionList.lookupSingleton(AllUsers.class);
10951173
}
10961174

1097-
private static void reload() {
1175+
private static void reload() throws IOException {
10981176
getInstance().byName.clear();
10991177
UserDetailsCache.get().invalidateAll();
11001178
scanAll();
@@ -1252,7 +1330,7 @@ public String resolveCanonicalId(String idOrFullName, Map<String, ?> context) {
12521330
UserDetails userDetails = UserDetailsCache.get().loadUserByUsername(idOrFullName);
12531331
return userDetails.getUsername();
12541332
} catch (UsernameNotFoundException x) {
1255-
LOGGER.log(Level.FINE, "not sure whether " + idOrFullName + " is a valid username or not", x);
1333+
LOGGER.log(Level.FINER, "not sure whether " + idOrFullName + " is a valid username or not", x);
12561334
} catch (ExecutionException x) {
12571335
LOGGER.log(Level.FINE, "could not look up " + idOrFullName, x);
12581336
} finally {

0 commit comments

Comments
 (0)