52
52
import jakarta .servlet .http .HttpServletResponse ;
53
53
import java .io .File ;
54
54
import java .io .IOException ;
55
+ import java .nio .file .Files ;
56
+ import java .nio .file .StandardCopyOption ;
55
57
import java .util .ArrayList ;
56
58
import java .util .Arrays ;
57
59
import java .util .Collection ;
58
60
import java .util .Collections ;
59
61
import java .util .HashSet ;
60
62
import java .util .List ;
63
+ import java .util .Locale ;
61
64
import java .util .Map ;
62
65
import java .util .Objects ;
63
66
import java .util .Set ;
67
70
import java .util .function .Predicate ;
68
71
import java .util .logging .Level ;
69
72
import java .util .logging .Logger ;
73
+ import java .util .regex .Pattern ;
70
74
import jenkins .model .IdStrategy ;
71
75
import jenkins .model .Jenkins ;
72
76
import jenkins .model .Loadable ;
73
77
import jenkins .model .ModelObjectWithContextMenu ;
74
78
import jenkins .scm .RunWithSCM ;
75
79
import jenkins .search .SearchGroup ;
80
+ import jenkins .security .HMACConfidentialKey ;
76
81
import jenkins .security .ImpersonatingUserDetailsService2 ;
77
82
import jenkins .security .LastGrantedAuthoritiesProperty ;
78
83
import jenkins .security .UserDetailsCache ;
@@ -174,7 +179,7 @@ public class User extends AbstractModelObject implements AccessControlled, Descr
174
179
175
180
@ SuppressFBWarnings (value = "SS_SHOULD_BE_STATIC" , justification = "Reserved for future use" )
176
181
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 ;
178
183
private volatile String fullName ;
179
184
private volatile String description ;
180
185
@@ -185,6 +190,8 @@ public class User extends AbstractModelObject implements AccessControlled, Descr
185
190
XSTREAM .alias ("user" , User .class );
186
191
}
187
192
193
+ private User () {}
194
+
188
195
private User (String id , String fullName ) {
189
196
this .id = id ;
190
197
this .fullName = fullName ;
@@ -199,6 +206,10 @@ public void load() {
199
206
private void load (String userId ) {
200
207
clearExistingProperties ();
201
208
loadFromUserConfigFile (userId );
209
+ fixUpAfterLoad ();
210
+ }
211
+
212
+ private void fixUpAfterLoad () {
202
213
removeNullsThatFailedToLoad ();
203
214
allocateDefaultPropertyInstancesAsNeeded ();
204
215
setUserToProperties ();
@@ -225,9 +236,10 @@ private void removeNullsThatFailedToLoad() {
225
236
}
226
237
227
238
private void loadFromUserConfigFile (String userId ) {
239
+ AllUsers .getInstance ().migrateUserIdMapper ();
228
240
XmlFile config = getConfigFile ();
229
241
try {
230
- if (config != null && config .exists ()) {
242
+ if (config .exists ()) {
231
243
config .unmarshal (this );
232
244
this .id = userId ;
233
245
}
@@ -241,8 +253,7 @@ private void clearExistingProperties() {
241
253
}
242
254
243
255
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 ));
246
257
}
247
258
248
259
/**
@@ -571,10 +582,10 @@ public void doSubmitDescription(StaplerRequest2 req, StaplerResponse2 rsp) throw
571
582
*/
572
583
private static @ Nullable User getOrCreateById (@ NonNull String id , @ NonNull String fullName , boolean create ) {
573
584
User u = AllUsers .get (id );
574
- if (u == null && ( create || UserIdMapper . getInstance (). isMapped ( id )) ) {
585
+ if (u == null && create ) {
575
586
u = new User (id , fullName );
576
587
AllUsers .put (id , u );
577
- if (!id .equals (fullName ) && ! UserIdMapper . getInstance (). isMapped ( id ) ) {
588
+ if (!id .equals (fullName )) {
578
589
try {
579
590
u .save ();
580
591
} catch (IOException x ) {
@@ -691,7 +702,6 @@ public void doSubmitDescription(StaplerRequest2 req, StaplerResponse2 rsp) throw
691
702
*/
692
703
@ Restricted (Beta .class )
693
704
public static void reload () throws IOException {
694
- UserIdMapper .getInstance ().reload ();
695
705
AllUsers .reload ();
696
706
}
697
707
@@ -708,6 +718,32 @@ public static void rekey() {
708
718
or greater issues in the realm change, could affect currently logged
709
719
in users and even the user making the change. */
710
720
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
+ }
711
747
reload ();
712
748
} catch (IOException e ) {
713
749
LOGGER .log (Level .SEVERE , "Failed to perform rekey operation." , e );
@@ -777,17 +813,9 @@ public static void clear() {
777
813
if (ExtensionList .lookup (AllUsers .class ).isEmpty ()) {
778
814
return ;
779
815
}
780
- UserIdMapper .getInstance ().clear ();
781
816
AllUsers .clear ();
782
817
}
783
818
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
- }
791
819
/**
792
820
* Returns the folder that store all the user information.
793
821
* Useful for plugins to save a user-specific file aside the config.xml.
@@ -799,11 +827,8 @@ private static File getUserFolderFor(String id) {
799
827
*/
800
828
801
829
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 ;
807
832
}
808
833
809
834
/**
@@ -813,6 +838,21 @@ static File getRootDir() {
813
838
return new File (Jenkins .get ().getRootDir (), "users" );
814
839
}
815
840
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
+
816
856
/**
817
857
* Is the ID allowed? Some are prohibited for security reasons. See SECURITY-166.
818
858
* <p>
@@ -852,39 +892,23 @@ public synchronized void save() throws IOException {
852
892
if (BulkChange .contains (this )) {
853
893
return ;
854
894
}
855
- XmlFile xmlFile = new XmlFile ( XSTREAM , constructUserConfigFile () );
895
+ XmlFile xmlFile = getConfigFile ( );
856
896
xmlFile .write (this );
857
897
SaveableListener .fireOnChange (this , xmlFile );
858
898
}
859
899
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
-
868
900
/**
869
901
* Deletes the data directory and removes this user from Hudson.
870
902
*
871
903
* @throws IOException if we fail to delete.
872
904
*/
873
905
public void delete () throws IOException {
874
906
String idKey = idStrategy ().keyFor (id );
875
- File existingUserFolder = getExistingUserFolder ();
876
- UserIdMapper .getInstance ().remove (id );
877
907
AllUsers .remove (id );
878
- deleteExistingUserFolder ( existingUserFolder );
908
+ Util . deleteRecursive ( getUserFolderFor ( id ) );
879
909
UserDetailsCache .get ().invalidate (idKey );
880
910
}
881
911
882
- private void deleteExistingUserFolder (File existingUserFolder ) throws IOException {
883
- if (existingUserFolder != null && existingUserFolder .exists ()) {
884
- Util .deleteRecursive (existingUserFolder );
885
- }
886
- }
887
-
888
912
/**
889
913
* Exposed remote API.
890
914
*/
@@ -947,7 +971,7 @@ public ACL getACL() {
947
971
public boolean canDelete () {
948
972
final IdStrategy strategy = idStrategy ();
949
973
return hasPermission (Jenkins .ADMINISTER ) && !strategy .equals (id , Jenkins .getAuthentication2 ().getName ())
950
- && UserIdMapper . getInstance (). isMapped ( id ) ;
974
+ && getUserFolder () != null ;
951
975
}
952
976
953
977
/**
@@ -1074,14 +1098,68 @@ private Object readResolve() {
1074
1098
@ Restricted (NoExternalUse .class )
1075
1099
public static final class AllUsers {
1076
1100
1101
+ private boolean migratedUserIdMapper ;
1077
1102
private final ConcurrentMap <String , User > byName = new ConcurrentHashMap <>();
1078
1103
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
+
1079
1116
@ 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
+ }
1084
1161
}
1162
+ LOGGER .fine (() -> "loaded " + byName .size () + " entries" );
1085
1163
}
1086
1164
1087
1165
/**
@@ -1094,7 +1172,7 @@ private static AllUsers getInstance() {
1094
1172
return ExtensionList .lookupSingleton (AllUsers .class );
1095
1173
}
1096
1174
1097
- private static void reload () {
1175
+ private static void reload () throws IOException {
1098
1176
getInstance ().byName .clear ();
1099
1177
UserDetailsCache .get ().invalidateAll ();
1100
1178
scanAll ();
@@ -1252,7 +1330,7 @@ public String resolveCanonicalId(String idOrFullName, Map<String, ?> context) {
1252
1330
UserDetails userDetails = UserDetailsCache .get ().loadUserByUsername (idOrFullName );
1253
1331
return userDetails .getUsername ();
1254
1332
} 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 );
1256
1334
} catch (ExecutionException x ) {
1257
1335
LOGGER .log (Level .FINE , "could not look up " + idOrFullName , x );
1258
1336
} finally {
0 commit comments