Skip to content

Commit 26fb299

Browse files
authored
Integrate user's profile photo to Jenkins avatar (#658)
1 parent 1f7a456 commit 26fb299

File tree

4 files changed

+202
-8
lines changed

4 files changed

+202
-8
lines changed

pom.xml

Lines changed: 4 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -5,7 +5,7 @@
55
<parent>
66
<groupId>org.jenkins-ci.plugins</groupId>
77
<artifactId>plugin</artifactId>
8-
<version>4.88</version>
8+
<version>5.6</version>
99
</parent>
1010

1111
<artifactId>azure-ad</artifactId>
@@ -37,8 +37,8 @@
3737
<project.reporting.outputEncoding>UTF-8</project.reporting.outputEncoding>
3838

3939
<!-- https://www.jenkins.io/doc/developer/plugin-development/choosing-jenkins-baseline/ -->
40-
<jenkins.baseline>2.440</jenkins.baseline>
41-
<jenkins.version>${jenkins.baseline}.3</jenkins.version>
40+
<jenkins.baseline>2.479</jenkins.baseline>
41+
<jenkins.version>${jenkins.baseline}.1</jenkins.version>
4242
<node.version>20.17.0</node.version>
4343
<npm.version>10.8.2</npm.version>
4444
<hpi.compatibleSinceVersion>391.v252da_e1dd39c</hpi.compatibleSinceVersion>
@@ -250,7 +250,7 @@
250250
<dependency>
251251
<groupId>io.jenkins.tools.bom</groupId>
252252
<artifactId>bom-${jenkins.baseline}.x</artifactId>
253-
<version>3435.v238d66a_043fb_</version>
253+
<version>4051.v78dce3ce8b_d6</version>
254254
<scope>import</scope>
255255
<type>pom</type>
256256
</dependency>

src/main/java/com/microsoft/jenkins/azuread/AzureSecurityRealm.java

Lines changed: 62 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -19,10 +19,13 @@
1919
import com.google.common.base.Suppliers;
2020
import com.microsoft.graph.http.GraphServiceException;
2121
import com.microsoft.graph.models.Group;
22+
import com.microsoft.graph.models.ProfilePhoto;
2223
import com.microsoft.graph.options.Option;
2324
import com.microsoft.graph.options.QueryOption;
2425
import com.microsoft.graph.requests.GraphServiceClient;
2526
import com.microsoft.graph.requests.GroupCollectionPage;
27+
import com.microsoft.graph.requests.ProfilePhotoRequestBuilder;
28+
import com.microsoft.jenkins.azuread.avatar.EntraAvatarProperty;
2629
import com.microsoft.jenkins.azuread.scribe.AzureAdApi;
2730
import com.microsoft.jenkins.azuread.utils.UUIDValidator;
2831
import com.thoughtworks.xstream.converters.Converter;
@@ -47,10 +50,14 @@
4750
import hudson.util.Secret;
4851
import io.jenkins.plugins.azuresdk.HttpClientRetriever;
4952

53+
import java.io.File;
54+
import java.nio.file.Files;
55+
import java.nio.file.StandardCopyOption;
5056
import javax.servlet.http.HttpSession;
5157

5258
import jenkins.model.Jenkins;
5359
import jenkins.security.SecurityListener;
60+
import jenkins.util.SystemProperties;
5461
import okhttp3.Request;
5562
import org.apache.commons.lang3.RandomStringUtils;
5663
import org.apache.commons.lang3.StringUtils;
@@ -146,8 +153,8 @@ public AccessToken getAccessToken() {
146153
tokenRequestContext.setScopes(singletonList(graphResource + ".default"));
147154

148155
AccessToken accessToken = ("Certificate".equals(credentialType) ? getClientCertificateCredential() : getClientSecretCredential())
149-
.getToken(tokenRequestContext)
150-
.block();
156+
.getToken(tokenRequestContext)
157+
.block();
151158

152159
if (accessToken == null) {
153160
throw new IllegalStateException("Access token null when it is required");
@@ -185,6 +192,7 @@ ClientCertificateCredential getClientCertificateCredential() {
185192
.httpClient(HttpClientRetriever.get())
186193
.build();
187194
}
195+
188196
public boolean isPromptAccount() {
189197
return promptAccount;
190198
}
@@ -230,6 +238,7 @@ public String getClientCertificateSecret() {
230238
public String getCredentialType() {
231239
return credentialType;
232240
}
241+
233242
public String getTenantSecret() {
234243
return tenant.getEncryptedValue();
235244
}
@@ -465,9 +474,14 @@ public HttpResponse doFinishLogin(StaplerRequest request)
465474

466475
// Enforce updating current identity
467476
SecurityContextHolder.getContext().setAuthentication(auth);
468-
updateIdentity(auth.getAzureAdUser(), User.current());
477+
User currentUser = User.current();
478+
updateIdentity(auth.getAzureAdUser(), currentUser);
469479

470480
SecurityListener.fireAuthenticated2(userDetails);
481+
482+
if (!isDisableGraphIntegration()) {
483+
updateAvatar(userDetails, currentUser);
484+
}
471485
} catch (Exception ex) {
472486
LOGGER.log(Level.SEVERE, "error", ex);
473487
throw ex;
@@ -480,6 +494,50 @@ public HttpResponse doFinishLogin(StaplerRequest request)
480494
}
481495
}
482496

497+
private void updateAvatar(AzureAdUser userDetails, User currentUser) {
498+
if (currentUser == null) {
499+
return;
500+
}
501+
try {
502+
if (SystemProperties.getBoolean(AzureSecurityRealm.class.getName() + ".disableAvatar", false)) {
503+
return;
504+
}
505+
ProfilePhotoRequestBuilder photosRequestBuilder = getAzureClient()
506+
.users(userDetails.getObjectID()).photos("48x48");
507+
LOGGER.finest("Fetching avatar metadata");
508+
ProfilePhoto profilePhoto = photosRequestBuilder.buildRequest().get();
509+
LOGGER.finest("Completed fetching avatar metadata");
510+
if (profilePhoto != null) {
511+
LOGGER.finest("Fetching avatar");
512+
try (InputStream inputStream = photosRequestBuilder.content().buildRequest().get()) {
513+
if (inputStream != null) {
514+
String mediaContentType = profilePhoto.additionalDataManager().get("@odata.mediaContentType")
515+
.getAsString();
516+
EntraAvatarProperty.AvatarImage avatarImage = new EntraAvatarProperty.AvatarImage(
517+
mediaContentType
518+
);
519+
EntraAvatarProperty entraAvatarProperty = new EntraAvatarProperty(avatarImage);
520+
File targetFile = new File(currentUser.getUserFolder(), "entra-avatar." + avatarImage.getFilenameSuffix());
521+
522+
Files.copy(
523+
inputStream,
524+
targetFile.toPath(),
525+
StandardCopyOption.REPLACE_EXISTING);
526+
currentUser.addProperty(entraAvatarProperty);
527+
LOGGER.finest("Saved avatar");
528+
}
529+
530+
} catch (IOException e) {
531+
LOGGER.log(Level.WARNING, "Failed to save profile photo for %s".formatted(currentUser.getId()), e);
532+
}
533+
} else {
534+
LOGGER.finest("No avatar found");
535+
}
536+
} catch (GraphServiceException e) {
537+
LOGGER.log(e.getResponseCode() == 404 ? Level.FINER : Level.WARNING, "Failed to get profile photo for %s".formatted(currentUser.getId()), e);
538+
}
539+
}
540+
483541
JwtClaims validateIdToken(String expectedNonce, String idToken) throws InvalidJwtException {
484542
JwtClaims claims = getJwtConsumer().processToClaims(idToken);
485543
final String responseNonce = (String) claims.getClaimValue("nonce");
@@ -878,7 +936,7 @@ private void updateIdentity(final AzureAdUser azureAdUser, final User u) {
878936
if (StringUtils.isNotBlank(azureAdUser.getEmail())) {
879937
UserProperty existing = u.getProperty(UserProperty.class);
880938
if (existing == null || !existing.hasExplicitlyConfiguredAddress()) {
881-
u.addProperty(new Mailer.UserProperty(azureAdUser.getEmail()));
939+
u.addProperty(new Mailer.UserProperty(azureAdUser.getEmail()));
882940
}
883941
}
884942
} catch (IOException e) {
Lines changed: 115 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,115 @@
1+
package com.microsoft.jenkins.azuread.avatar;
2+
3+
import edu.umd.cs.findbugs.annotations.NonNull;
4+
import hudson.Extension;
5+
import hudson.model.Action;
6+
import hudson.model.User;
7+
import hudson.model.UserProperty;
8+
import hudson.model.UserPropertyDescriptor;
9+
import java.io.File;
10+
import java.io.FileInputStream;
11+
import java.util.logging.Level;
12+
import java.util.logging.Logger;
13+
import jenkins.model.Jenkins;
14+
import org.kohsuke.stapler.StaplerRequest2;
15+
import org.kohsuke.stapler.StaplerResponse2;
16+
17+
public class EntraAvatarProperty extends UserProperty implements Action {
18+
private static final Logger LOGGER = Logger.getLogger(EntraAvatarProperty.class.getName());
19+
20+
private final AvatarImage avatarImage;
21+
22+
public EntraAvatarProperty(AvatarImage avatarImage) {
23+
this.avatarImage = avatarImage;
24+
}
25+
26+
public String getAvatarUrl() {
27+
if (isHasAvatar()) {
28+
return getAvatarImageUrl();
29+
}
30+
31+
return null;
32+
}
33+
34+
private String getAvatarImageUrl() {
35+
return "%s%s/%s/image".formatted(Jenkins.get().getRootUrl(), user.getUrl(), getUrlName());
36+
}
37+
38+
public boolean isHasAvatar() {
39+
return avatarImage != null && avatarImage.isValid();
40+
}
41+
42+
/**
43+
* Used to serve images as part of {@link EntraAvatarResolver}.
44+
*/
45+
public void doImage(StaplerRequest2 req, StaplerResponse2 rsp) {
46+
if (avatarImage == null) {
47+
LOGGER.log(Level.WARNING, "No image set for user '" + user.getId() + "'");
48+
return;
49+
}
50+
51+
String imageFileName = "entra-avatar." + avatarImage.getFilenameSuffix();
52+
File file = new File(user.getUserFolder(), imageFileName);
53+
if (!file.exists()) {
54+
LOGGER.log(Level.WARNING, "Avatar image for user '" + user.getId() + "' does not exist");
55+
return;
56+
}
57+
58+
try (FileInputStream fileInputStream = new FileInputStream(file); ) {
59+
rsp.setContentType(avatarImage.mimeType);
60+
rsp.serveFile(
61+
req, fileInputStream, file.lastModified(), file.length(), imageFileName);
62+
} catch (Exception e) {
63+
LOGGER.log(Level.SEVERE, "Unable to write image for user '" + user.getId() + "'", e);
64+
}
65+
}
66+
67+
public String getDisplayName() {
68+
return "Entra Avatar";
69+
}
70+
71+
public String getIconFileName() {
72+
return null;
73+
}
74+
75+
public String getUrlName() {
76+
return "entra-avatar";
77+
}
78+
79+
@Extension
80+
public static class DescriptorImpl extends UserPropertyDescriptor {
81+
82+
@Override
83+
@NonNull
84+
public String getDisplayName() {
85+
return "Entra Avatar";
86+
}
87+
88+
@Override
89+
public boolean isEnabled() {
90+
return false;
91+
}
92+
93+
@Override
94+
public UserProperty newInstance(User user) {
95+
return new EntraAvatarProperty(null);
96+
}
97+
}
98+
99+
public static class AvatarImage {
100+
private final String mimeType;
101+
102+
public AvatarImage(String mimeType) {
103+
this.mimeType = mimeType;
104+
}
105+
106+
public String getFilenameSuffix() {
107+
return mimeType.split("/")[1]
108+
.split("\\+")[0];
109+
}
110+
111+
public boolean isValid() {
112+
return mimeType != null;
113+
}
114+
}
115+
}
Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,21 @@
1+
package com.microsoft.jenkins.azuread.avatar;
2+
3+
import hudson.Extension;
4+
import hudson.model.User;
5+
import hudson.tasks.UserAvatarResolver;
6+
7+
@Extension(ordinal = -1)
8+
public class EntraAvatarResolver extends UserAvatarResolver {
9+
@Override
10+
public String findAvatarFor(User user, int width, int height) {
11+
if (user != null) {
12+
EntraAvatarProperty avatarProperty = user.getProperty(EntraAvatarProperty.class);
13+
14+
if (avatarProperty != null) {
15+
return avatarProperty.getAvatarUrl();
16+
}
17+
}
18+
19+
return null;
20+
}
21+
}

0 commit comments

Comments
 (0)