1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22 package org.opencastproject.security.jwt;
23
24 import org.opencastproject.security.api.Organization;
25 import org.opencastproject.security.api.SecurityService;
26 import org.opencastproject.security.api.UserDirectoryService;
27 import org.opencastproject.security.impl.jpa.JpaOrganization;
28 import org.opencastproject.security.impl.jpa.JpaRole;
29 import org.opencastproject.security.impl.jpa.JpaUserReference;
30 import org.opencastproject.security.util.SecurityUtil;
31 import org.opencastproject.userdirectory.api.UserReferenceProvider;
32
33 import com.google.common.cache.Cache;
34 import com.google.common.cache.CacheBuilder;
35 import com.nimbusds.jose.JOSEException;
36 import com.nimbusds.jwt.SignedJWT;
37
38 import org.apache.commons.lang3.StringUtils;
39 import org.osgi.service.component.annotations.Reference;
40 import org.slf4j.Logger;
41 import org.slf4j.LoggerFactory;
42 import org.springframework.beans.factory.InitializingBean;
43 import org.springframework.context.expression.MapAccessor;
44 import org.springframework.expression.Expression;
45 import org.springframework.expression.ExpressionParser;
46 import org.springframework.expression.spel.standard.SpelExpressionParser;
47 import org.springframework.expression.spel.support.StandardEvaluationContext;
48 import org.springframework.security.core.userdetails.UserDetailsService;
49 import org.springframework.security.core.userdetails.UsernameNotFoundException;
50 import org.springframework.util.Assert;
51
52 import java.text.ParseException;
53 import java.util.ArrayList;
54 import java.util.Date;
55 import java.util.HashSet;
56 import java.util.List;
57 import java.util.Set;
58 import java.util.concurrent.TimeUnit;
59 import java.util.function.Consumer;
60
61
62
63
64 public class DynamicLoginHandler implements InitializingBean, JWTLoginHandler {
65
66
67 private static final Logger logger = LoggerFactory.getLogger(DynamicLoginHandler.class);
68
69
70 private UserDetailsService userDetailsService = null;
71
72
73 private UserDirectoryService userDirectoryService = null;
74
75
76 private UserReferenceProvider userReferenceProvider = null;
77
78
79 private SecurityService securityService = null;
80
81
82 private String jwksUrl = null;
83
84
85 private int jwksTimeToLive = 1000 * 60 * 60;
86
87
88 private int jwksRefreshTimeout = 1000 * 60;
89
90
91 private String secret = null;
92
93
94 private List<String> expectedAlgorithms = null;
95
96
97 private List<String> claimConstraints = null;
98
99
100 private String usernameMapping = null;
101
102
103 private String nameMapping = null;
104
105
106 private String emailMapping = null;
107
108
109 private boolean ocStandardRoleMappings = true;
110
111
112 private List<String> roleMappings = null;
113
114
115 private JWKSetProvider jwkProvider;
116
117
118 private int jwtCacheSize = 500;
119
120
121 private int jwtCacheExpiresIn = 60;
122
123
124 private Cache<String, CachedJWT> cache;
125
126 @Override
127 public void afterPropertiesSet() {
128 Assert.notNull(userDetailsService, "A UserDetailsService must be set");
129 Assert.notNull(userDirectoryService, "A UserDirectoryService must be set");
130 Assert.notNull(userReferenceProvider, "A UserReferenceProvider must be set");
131 Assert.notNull(securityService, "A SecurityService must be set");
132 Assert.isTrue(!(StringUtils.isNotBlank(jwksUrl) && StringUtils.isNotBlank(secret)),
133 "A JWKS URL and a secret cannot be set at the same time");
134 Assert.notEmpty(expectedAlgorithms, "Expected algorithms must be set");
135 Assert.notNull(claimConstraints, "Claim constraints must be set");
136 Assert.notNull(usernameMapping, "User name mapping must be set");
137 Assert.notNull(nameMapping, "Name mapping must be set");
138 Assert.notNull(emailMapping, "Email mapping must be set");
139 Assert.isTrue(roleMappings != null || ocStandardRoleMappings,
140 "Role mappings must be set if ocStandardRoleMappings is false");
141
142 if (StringUtils.isBlank(jwksUrl) && StringUtils.isBlank(secret)) {
143 logger.info("JWT login handler disabled as neither 'jwksUrl' nor 'secret' are set");
144 }
145
146 if (jwksUrl != null) {
147 jwkProvider = new JWKSetProvider(jwksUrl, jwksTimeToLive, jwksRefreshTimeout);
148 }
149 userReferenceProvider.setRoleProvider(new JWTRoleProvider(securityService, userReferenceProvider));
150 cache = CacheBuilder.newBuilder()
151 .maximumSize(jwtCacheSize)
152 .expireAfterWrite(jwtCacheExpiresIn, TimeUnit.MINUTES)
153 .build();
154 }
155
156 @Override
157 public String handleToken(String token) {
158 if (jwkProvider == null && secret == null) {
159 logger.debug("neither jwksURL nor secret set: ignoring JWT");
160 return null;
161 }
162
163 try {
164 String signature = extractSignature(token);
165 CachedJWT cachedJwt = cache.getIfPresent(signature);
166
167 if (cachedJwt == null) {
168
169 SignedJWT jwt = decodeAndValidate(token);
170 String username = extractUsername(jwt);
171
172 try {
173 if (userDetailsService.loadUserByUsername(username) != null) {
174 existingUserLogin(username, jwt);
175 }
176 } catch (UsernameNotFoundException e) {
177 newUserLogin(username, jwt);
178 }
179
180 userDirectoryService.invalidate(username);
181 cache.put(jwt.getSignature().toString(), new CachedJWT(jwt, username));
182 return username;
183 } else {
184
185 if (cachedJwt.hasExpired()) {
186 cache.invalidate(signature);
187 throw new JOSEException("JWT token is not valid anymore");
188 }
189 logger.debug("Using decoded and validated JWT from cache");
190 return cachedJwt.getUsername();
191 }
192 } catch (ParseException | JOSEException exception) {
193 logger.debug(exception.getMessage());
194 }
195
196 return null;
197 }
198
199
200
201
202
203
204
205
206 private SignedJWT decodeAndValidate(String token) throws ParseException, JOSEException {
207 SignedJWT jwt;
208
209 if (jwksUrl != null) {
210 jwt = JWTVerifier.verify(token, jwkProvider, claimConstraints);
211 } else {
212 jwt = JWTVerifier.verify(token, secret, claimConstraints);
213 }
214
215 if (!expectedAlgorithms.contains(jwt.getHeader().getAlgorithm().getName())) {
216 throw new JOSEException(
217 "JWT token was signed with an unexpected algorithm '" + jwt.getHeader().getAlgorithm() + "'"
218 );
219 }
220
221 return jwt;
222 }
223
224
225
226
227
228
229
230 private String extractSignature(String token) throws JOSEException {
231 String[] parts = token.split("\\.");
232 if (parts.length != 3) {
233 throw new JOSEException("Given token is not in a valid JWT format");
234 }
235 return parts[2];
236 }
237
238
239
240
241
242
243
244 private String extractUsername(SignedJWT jwt) throws ParseException {
245 String username = evaluateMapping(jwt, usernameMapping);
246 Assert.isTrue(StringUtils.isNotBlank(username), "Extracted username is blank");
247 return username;
248 }
249
250
251
252
253
254
255
256 private String extractName(SignedJWT jwt) throws ParseException {
257 String name = evaluateMapping(jwt, nameMapping);
258 Assert.isTrue(StringUtils.isNotBlank(name), "Extracted name is blank");
259 return name;
260 }
261
262
263
264
265
266
267
268 private String extractEmail(SignedJWT jwt) throws ParseException {
269 String email = evaluateMapping(jwt, emailMapping);
270 Assert.isTrue(StringUtils.isNotBlank(email), "Extracted email is blank");
271 return email;
272 }
273
274
275
276
277
278
279
280 private Set<JpaRole> extractRoles(SignedJWT jwt) throws ParseException {
281 JpaOrganization organization = fromOrganization(securityService.getOrganization());
282 Set<JpaRole> roles = new HashSet<>();
283 Consumer<String> addRole = (String role) -> {
284 if (StringUtils.isNotBlank(role)) {
285 roles.add(new JpaRole(role, organization));
286 }
287 };
288
289
290 if (ocStandardRoleMappings) {
291
292 try {
293 var rolesClaim = jwt.getJWTClaimsSet().getStringArrayClaim("roles");
294 if (rolesClaim != null) {
295 for (String r : rolesClaim) {
296 addRole.accept(r);
297 }
298 }
299 } catch (ParseException e) {
300 logger.debug("claim 'roles' is not an array of strings, ignoring");
301 }
302
303
304 try {
305 var ocClaim = jwt.getJWTClaimsSet().getJSONObjectClaim("oc");
306 if (ocClaim != null) {
307 for (var entry : ocClaim.entrySet()) {
308 var key = entry.getKey();
309 var parts = key.split(":", 2);
310 if (parts.length != 2) {
311 logger.debug("key in 'oc' claim does not start with 'x:' -> ignoring");
312 continue;
313 }
314 var type = parts[0];
315 var id = parts[1];
316
317 try {
318 for (var actionObj : (List<?>) entry.getValue()) {
319 var action = (String) actionObj;
320 if (action.isBlank()) {
321 continue;
322 }
323
324 if (type.equals("e")) {
325 addRole.accept(SecurityUtil.getEpisodeRoleId(id, action));
326 } else {
327 logger.debug("in 'oc' claim: granting access to item type '{}' is not yet supported", type);
328 }
329 }
330 } catch (ClassCastException e) {
331 logger.debug("value in 'oc' claim is not a string array -> ignoring");
332 continue;
333 }
334 }
335 }
336 } catch (ParseException e) {
337 logger.debug("claim 'oc' is not an array of strings, ignoring");
338 }
339 }
340
341 for (String mapping : (roleMappings == null ? new ArrayList<String>() : roleMappings)) {
342 ExpressionParser parser = new SpelExpressionParser();
343 Expression exp = parser.parseExpression(mapping);
344 StandardEvaluationContext ctx = new StandardEvaluationContext();
345 ctx.addPropertyAccessor(new MapAccessor());
346 Object value = exp.getValue(ctx, jwt.getJWTClaimsSet().getClaims());
347 if (value != null) {
348
349 if (value instanceof String) {
350 addRole.accept((String) value);
351 } else if (value.getClass().isArray()) {
352 for (var role : (Object[]) value) {
353 addRole.accept((String) role);
354 }
355 } else {
356 for (var role : (List<?>) value) {
357 addRole.accept((String) role);
358 }
359 }
360 }
361 }
362 Assert.notEmpty(roles, "No roles could be extracted");
363 return roles;
364 }
365
366
367
368
369
370
371
372
373
374 private String evaluateMapping(SignedJWT jwt, String mapping) throws ParseException {
375 ExpressionParser parser = new SpelExpressionParser();
376 Expression exp = parser.parseExpression(mapping);
377 StandardEvaluationContext ctx = new StandardEvaluationContext();
378 ctx.addPropertyAccessor(new MapAccessor());
379 return exp.getValue(ctx, jwt.getJWTClaimsSet().getClaims(), String.class);
380 }
381
382
383
384
385
386
387
388 public void newUserLogin(String username, SignedJWT jwt) throws ParseException {
389
390 JpaUserReference userReference = new JpaUserReference(username, extractName(jwt), extractEmail(jwt), MECH_JWT,
391 new Date(), fromOrganization(securityService.getOrganization()), extractRoles(jwt));
392
393 logger.debug("JWT user '{}' logged in for the first time", username);
394 userReferenceProvider.addUserReference(userReference, MECH_JWT);
395 }
396
397
398
399
400
401
402
403 public void existingUserLogin(String username, SignedJWT jwt) throws ParseException {
404 Organization organization = securityService.getOrganization();
405
406
407 JpaUserReference userReference = userReferenceProvider.findUserReference(username, organization.getId());
408 if (userReference == null) {
409 throw new UsernameNotFoundException("User reference '" + username + "' was not found");
410 }
411
412
413 userReference.setName(extractName(jwt));
414 userReference.setEmail(extractEmail(jwt));
415 userReference.setLastLogin(new Date());
416 userReference.setRoles(extractRoles(jwt));
417
418 logger.debug("JWT user '{}' logged in", username);
419 userReferenceProvider.updateUserReference(userReference);
420 }
421
422
423
424
425
426
427
428 private JpaOrganization fromOrganization(Organization org) {
429 if (org instanceof JpaOrganization) {
430 return (JpaOrganization) org;
431 }
432
433 return new JpaOrganization(org.getId(), org.getName(), org.getServers(), org.getAdminRole(), org.getAnonymousRole(),
434 org.getProperties());
435 }
436
437
438
439
440
441
442 @Reference
443 public void setUserDetailsService(UserDetailsService userDetailsService) {
444 this.userDetailsService = userDetailsService;
445 }
446
447
448
449
450
451
452 @Reference
453 public void setUserDirectoryService(UserDirectoryService userDirectoryService) {
454 this.userDirectoryService = userDirectoryService;
455 }
456
457
458
459
460
461
462 @Reference
463 public void setSecurityService(SecurityService securityService) {
464 this.securityService = securityService;
465 }
466
467
468
469
470
471
472 @Reference
473 public void setUserReferenceProvider(UserReferenceProvider userReferenceProvider) {
474 this.userReferenceProvider = userReferenceProvider;
475 }
476
477
478
479
480
481
482 public void setJwksUrl(String jwksUrl) {
483 this.jwksUrl = jwksUrl;
484 }
485
486
487
488
489
490
491 public void setJwksTimeToLive(int jwksTimeToLive) {
492 this.jwksTimeToLive = jwksTimeToLive;
493 }
494
495
496
497
498
499
500 public void setSecret(String secret) {
501 this.secret = secret;
502 }
503
504
505
506
507
508
509 public void setExpectedAlgorithms(List<String> expectedAlgorithms) {
510 this.expectedAlgorithms = expectedAlgorithms;
511 }
512
513
514
515
516
517
518 public void setClaimConstraints(List<String> claimConstraints) {
519 this.claimConstraints = claimConstraints;
520 }
521
522
523
524
525
526 public void setUsernameMapping(String usernameMapping) {
527 this.usernameMapping = usernameMapping;
528 }
529
530
531
532
533
534
535 public void setNameMapping(String nameMapping) {
536 this.nameMapping = nameMapping;
537 }
538
539
540
541
542
543 public void setEmailMapping(String emailMapping) {
544 this.emailMapping = emailMapping;
545 }
546
547 public void setOcStandardRoleMappings(boolean ocStandardRoleMappings) {
548 this.ocStandardRoleMappings = ocStandardRoleMappings;
549 }
550
551
552
553
554
555
556 public void setRoleMappings(List<String> roleMappings) {
557 this.roleMappings = roleMappings;
558 }
559
560
561
562
563
564
565 public void setJwtCacheSize(int jwtCacheSize) {
566 this.jwtCacheSize = jwtCacheSize;
567 }
568
569
570
571
572
573
574 public void setJwtCacheExpiresIn(int jwtCacheExpiresIn) {
575 this.jwtCacheExpiresIn = jwtCacheExpiresIn;
576 }
577
578 }