001/* 002 * JGrapes Event Driven Framework 003 * Copyright (C) 2022 Michael N. Lipp 004 * 005 * This program is free software; you can redistribute it and/or modify it 006 * under the terms of the GNU Affero General Public License as published by 007 * the Free Software Foundation; either version 3 of the License, or 008 * (at your option) any later version. 009 * 010 * This program is distributed in the hope that it will be useful, but 011 * WITHOUT ANY WARRANTY; without even the implied warranty of MERCHANTABILITY 012 * or FITNESS FOR A PARTICULAR PURPOSE. See the GNU Affero General Public License 013 * for more details. 014 * 015 * You should have received a copy of the GNU Affero General Public License along 016 * with this program; if not, see <http://www.gnu.org/licenses/>. 017 */ 018 019package org.jgrapes.webconlet.oidclogin; 020 021import at.favre.lib.crypto.bcrypt.BCrypt; 022import com.fasterxml.jackson.databind.ObjectMapper; 023import freemarker.core.ParseException; 024import freemarker.template.MalformedTemplateNameException; 025import freemarker.template.Template; 026import freemarker.template.TemplateException; 027import freemarker.template.TemplateNotFoundException; 028import jakarta.mail.internet.AddressException; 029import jakarta.mail.internet.InternetAddress; 030import java.beans.ConstructorProperties; 031import java.io.IOException; 032import java.io.StringWriter; 033import java.util.Collections; 034import java.util.HashSet; 035import java.util.List; 036import java.util.Locale; 037import java.util.Map; 038import java.util.Optional; 039import java.util.Set; 040import java.util.concurrent.ConcurrentHashMap; 041import java.util.concurrent.Future; 042import java.util.function.Function; 043import java.util.logging.Level; 044import java.util.stream.Collectors; 045import javax.security.auth.Subject; 046import org.jgrapes.core.Channel; 047import org.jgrapes.core.Components; 048import org.jgrapes.core.Event; 049import org.jgrapes.core.EventPipeline; 050import org.jgrapes.core.Manager; 051import org.jgrapes.core.annotation.Handler; 052import org.jgrapes.http.LanguageSelector.Selection; 053import org.jgrapes.http.events.DiscardSession; 054import org.jgrapes.io.events.Close; 055import org.jgrapes.util.events.ConfigurationUpdate; 056import org.jgrapes.webconsole.base.Conlet.RenderMode; 057import org.jgrapes.webconsole.base.ConletBaseModel; 058import org.jgrapes.webconsole.base.ConsoleConnection; 059import org.jgrapes.webconsole.base.ConsoleUser; 060import org.jgrapes.webconsole.base.WebConsoleUtils; 061import org.jgrapes.webconsole.base.events.AddConletRequest; 062import org.jgrapes.webconsole.base.events.AddConletType; 063import org.jgrapes.webconsole.base.events.AddPageResources.ScriptResource; 064import org.jgrapes.webconsole.base.events.CloseModalDialog; 065import org.jgrapes.webconsole.base.events.ConsolePrepared; 066import org.jgrapes.webconsole.base.events.ConsoleReady; 067import org.jgrapes.webconsole.base.events.DisplayNotification; 068import org.jgrapes.webconsole.base.events.NotifyConletModel; 069import org.jgrapes.webconsole.base.events.NotifyConletView; 070import org.jgrapes.webconsole.base.events.OpenModalDialog; 071import org.jgrapes.webconsole.base.events.RenderConlet; 072import org.jgrapes.webconsole.base.events.RenderConletRequestBase; 073import org.jgrapes.webconsole.base.events.SetLocale; 074import org.jgrapes.webconsole.base.events.SimpleConsoleCommand; 075import org.jgrapes.webconsole.base.events.UserAuthenticated; 076import org.jgrapes.webconsole.base.events.UserLoggedOut; 077import org.jgrapes.webconsole.base.freemarker.FreeMarkerConlet; 078 079/** 080 * A login conlet for OIDC based logins with a fallback to password 081 * based logins. 082 * 083 * OIDC providers can be configured as property "oidcProviders" of the conlet: 084 * ```yaml 085 * "...": 086 * "/LoginConlet": 087 * oidcProviders: 088 * - name: my-provider 089 * displayName: My Provider 090 * configurationEndpoint: https://test.com/.well-known/openid-configuration 091 * # If no configurationEndpoint is available, the authorization 092 * # endpointEndpoint and the tokenEndpoint may be configured instead 093 * clientId: "WebConsoleTest" 094 * secret: "(unknown)" 095 * # The size of the popup window for the provider's dialog 096 * popup: 097 * # Size of the popup windows for authentication. Either 098 * # relative to the browser window's size or absolute in pixels 099 * factor: 0.6 100 * # width: 1600 101 * # height: 600 102 * # Only users with one of the roles listed here are allowed to login. 103 * # The check is performed against the roles reported by the provider 104 * # before any role mappings are applied (see below). 105 * # An empty role name in this list allows users without any role 106 * # to login. 107 * authorizedRoles: 108 * - "admin" 109 * - "user" 110 * - "" 111 * # Mappings to be applied to the preferred user name reported 112 * # by the provider. The list is evaluated up to the first match. 113 * userMappings: 114 * - from: "(.*)" 115 * to: "$1@oidc" 116 * # Mappings to be applied to the role names reported by the 117 * # provider. The list is evaluated up to the first match. 118 * roleMappings: 119 * - from: "(.*)" 120 * to: "$1@oidc" 121 * ``` 122 * 123 * The user id of the authenticated user is taken from the ID token's 124 * claim `preferred_username`, the display name from the claim `name`. 125 * Roles are created from the ID token's claim `roles`. Reporting the 126 * latter has usually to be added in the provider's configuration. 127 * Of course, roles can also be added independently based on the 128 * user id by using another component, thus separating the authentication 129 * by the OIDC provider from the role management. 130 * 131 * The component requires that an instance of {@link OidcClient} 132 * handles the {@link StartOidcLogin} events fired on the component's 133 * channel. 134 * 135 * As a fallback, local users can be configured as property "users": 136 * ```yaml 137 * "...": 138 * "/LoginConlet": 139 * users: 140 * - name: admin 141 * # Full name is optional 142 * fullName: Administrator 143 * password: "$2b$05$NiBd74ZGdplLC63ePZf1f.UtjMKkbQ23cQoO2OKOFalDBHWAOy21." 144 * - name: test 145 * fullName: Test Account 146 * email: test@test.com 147 * password: "$2b$05$hZaI/jToXf/d3BctZdT38Or7H7h6Pn2W3WiB49p5AyhDHFkkYCvo2" 148 * ``` 149 * 150 * Passwords are hashed using bcrypt. 151 * 152 * The local login part of the dialog is only shown if at least one user is 153 * configured. 154 */ 155@SuppressWarnings({ "PMD.DataflowAnomalyAnalysis", "PMD.CouplingBetweenObjects", 156 "PMD.ExcessiveImports" }) 157public class LoginConlet extends FreeMarkerConlet<LoginConlet.AccountModel> { 158 159 private static final String PENDING_CONSOLE_PREPARED 160 = "pendingConsolePrepared"; 161 private Map<String, OidcProviderData> providers 162 = new ConcurrentHashMap<>(); 163 private Map<String, Map<String, String>> users 164 = new ConcurrentHashMap<>(); 165 166 /** 167 * Creates a new component with its channel set to the given channel. 168 * 169 * @param componentChannel the channel that the component's handlers listen 170 * on by default and that {@link Manager#fire(Event, Channel...)} 171 * sends the event to 172 */ 173 public LoginConlet(Channel componentChannel) { 174 super(componentChannel); 175 } 176 177 @Override 178 protected String generateInstanceId(AddConletRequest event, 179 ConsoleConnection session) { 180 return "Singleton"; 181 } 182 183 /** 184 * Register conlet. 185 * 186 * @param event the event 187 * @param channel the channel 188 * @throws TemplateNotFoundException the template not found exception 189 * @throws MalformedTemplateNameException the malformed template name exception 190 * @throws ParseException the parse exception 191 * @throws IOException Signals that an I/O exception has occurred. 192 */ 193 @Handler 194 public void onConsoleReady(ConsoleReady event, ConsoleConnection channel) 195 throws TemplateNotFoundException, MalformedTemplateNameException, 196 ParseException, IOException { 197 // Define providers 198 StringBuffer script = new StringBuffer(1000); 199 script.append("window.orgJGrapesOidcLogin" 200 + " = window.orgJGrapesOidcLogin || {};" 201 + "window.orgJGrapesOidcLogin.providers = [];"); 202 boolean first = true; 203 for (var e : providers.entrySet()) { 204 if (first) { 205 script.append("let "); 206 first = false; 207 } 208 script.append("provider = new Map();" 209 + "window.orgJGrapesOidcLogin.providers.push(provider);" 210 + "provider.set('name', '").append(e.getKey()) 211 .append("');provider.set('displayName', '") 212 .append(e.getValue().displayName()).append("');"); 213 if (e.getValue().popup() != null) { 214 for (var d : e.getValue().popup().entrySet()) { 215 String key = "popup" + d.getKey().substring(0, 1) 216 .toUpperCase() + d.getKey().substring(1); 217 script.append("provider.set('").append(key).append("', '") 218 .append(d.getValue()).append("');"); 219 } 220 } 221 } 222 var providerDefs = script.toString(); 223 // Add conlet resources to page 224 channel.respond(new AddConletType(type()) 225 .addScript(new ScriptResource() 226 .setScriptUri(event.renderSupport().conletResource( 227 type(), "Login-functions.js")) 228 .setScriptType("module")) 229 .addScript(new ScriptResource().setScriptSource(providerDefs)) 230 .addCss(event.renderSupport(), WebConsoleUtils.uriFromPath( 231 "Login-style.css")) 232 .addPageContent("headerIcons", Map.of("priority", "1000")) 233 .addRenderMode(RenderMode.Content)); 234 } 235 236 /** 237 * As a model has already been created in {@link #doUpdateConletState}, 238 * the "new" model may already exist in the session. 239 */ 240 @Override 241 protected Optional<AccountModel> createNewState(AddConletRequest event, 242 ConsoleConnection session, String conletId) throws Exception { 243 Optional<AccountModel> model 244 = stateFromSession(session.session(), conletId); 245 if (model.isPresent()) { 246 return model; 247 } 248 return super.createNewState(event, session, conletId); 249 } 250 251 @Override 252 protected Optional<AccountModel> createStateRepresentation(Event<?> event, 253 ConsoleConnection channel, String conletId) throws IOException { 254 return Optional.of(new AccountModel(conletId)); 255 } 256 257 /** 258 * The component can be configured with events that include 259 * a path (see @link {@link ConfigurationUpdate#paths()}) 260 * that matches this components path (see {@link Manager#componentPath()}). 261 * 262 * The following properties are recognized: 263 * 264 * `users` 265 * : See {@link LoginConlet}. 266 * 267 * @param event the event 268 */ 269 @SuppressWarnings("unchecked") 270 @Handler 271 public void onConfigUpdate(ConfigurationUpdate event) { 272 users = event.structured(componentPath()) 273 .map(c -> (List<Map<String, String>>) c.get("users")) 274 .orElseGet(Collections::emptyList).stream() 275 .collect(Collectors.toMap(e -> e.get("name"), Function.identity())); 276 ObjectMapper mapper = new ObjectMapper(); 277 providers = event.structured(componentPath()) 278 .map(c -> (List<Map<String, String>>) c.get("oidcProviders")) 279 .orElseGet(Collections::emptyList).stream() 280 .map(e -> mapper.convertValue(e, OidcProviderData.class)) 281 .collect( 282 Collectors.toMap(OidcProviderData::name, Function.identity())); 283 } 284 285 /** 286 * Handle web console page loaded. 287 * 288 * @param event the event 289 * @param channel the channel 290 * @throws IOException 291 * @throws ParseException 292 * @throws MalformedTemplateNameException 293 * @throws TemplateNotFoundException 294 */ 295 @Handler(priority = 1000) 296 public void onConsolePrepared(ConsolePrepared event, 297 ConsoleConnection channel) 298 throws TemplateNotFoundException, MalformedTemplateNameException, 299 ParseException, IOException { 300 // If we are logged in, proceed 301 if (channel.session().containsKey(Subject.class)) { 302 return; 303 } 304 305 // Suspend handling and save event "in" channel. 306 event.suspendHandling(); 307 channel.setAssociated(PENDING_CONSOLE_PREPARED, event); 308 309 // Create model and save in session. 310 String conletId = type() + TYPE_INSTANCE_SEPARATOR + "Singleton"; 311 AccountModel accountModel = new AccountModel(conletId); 312 accountModel.setDialogOpen(true); 313 putInSession(channel.session(), conletId, accountModel); 314 315 // Render login dialog 316 var fmModel = fmSessionModel(channel.session()); 317 fmModel.put("hasUser", !users.isEmpty()); 318 fmModel.put("providers", providers); 319 Template tpl = freemarkerConfig().getTemplate("Login-dialog.ftl.html"); 320 var bundle = resourceBundle(channel.locale()); 321 channel.respond(new OpenModalDialog(type(), conletId, 322 processTemplate(event, tpl, fmModel)) 323 .addOption("title", bundle.getString("title")) 324 .addOption("cancelable", false) 325 .addOption("applyLabel", 326 providers.isEmpty() ? bundle.getString("Log in") : "") 327 .addOption("useSubmit", true)); 328 } 329 330 private Future<String> processTemplate( 331 Event<?> request, Template template, 332 Object dataModel) { 333 return request.processedBy().map(EventPipeline::executorService) 334 .orElse(Components.defaultExecutorService()).submit(() -> { 335 StringWriter out = new StringWriter(); 336 try { 337 template.process(dataModel, out); 338 } catch (TemplateException | IOException e) { 339 throw new IllegalArgumentException(e); 340 } 341 return out.toString(); 342 343 }); 344 } 345 346 @Override 347 protected Set<RenderMode> doRenderConlet(RenderConletRequestBase<?> event, 348 ConsoleConnection channel, String conletId, 349 AccountModel model) throws Exception { 350 Set<RenderMode> renderedAs = new HashSet<>(); 351 if (event.renderAs().contains(RenderMode.Content)) { 352 Template tpl 353 = freemarkerConfig().getTemplate("Login-status.ftl.html"); 354 channel.respond(new RenderConlet(type(), conletId, 355 processTemplate(event, tpl, 356 fmModel(event, channel, conletId, model))) 357 .setRenderAs(RenderMode.Content)); 358 channel.respond(new NotifyConletView(type(), conletId, 359 "updateUser", 360 WebConsoleUtils.userFromSession(channel.session()) 361 .map(ConsoleUser::getDisplayName).orElse(null))); 362 renderedAs.add(RenderMode.Content); 363 } 364 return renderedAs; 365 } 366 367 @Override 368 @SuppressWarnings({ "PMD.AvoidLiteralsInIfCondition", 369 "PMD.AvoidLiteralsInIfCondition", "PMD.AvoidLiteralsInIfCondition" }) 370 protected void doUpdateConletState(NotifyConletModel event, 371 ConsoleConnection connection, AccountModel model) throws Exception { 372 if ("loginData".equals(event.method())) { 373 attemptLocalLogin(event, connection, model); 374 return; 375 } 376 if ("useProvider".equals(event.method())) { 377 var locales = Optional.ofNullable( 378 (Selection) connection.session().get(Selection.class)) 379 .map(Selection::get).orElse(new Locale[0]); 380 fire(new StartOidcLogin(providers.get(event.params().asString(0)), 381 locales).setAssociated(this, 382 new LoginContext(connection, model))); 383 return; 384 } 385 if ("logout".equals(event.method())) { 386 Optional.ofNullable((Subject) connection.session() 387 .get(Subject.class)).map(UserLoggedOut::new).map(this::fire); 388 connection.responsePipeline() 389 .fire(new Close(), connection.upstreamChannel()).get(); 390 connection.close(); 391 connection.respond(new DiscardSession(connection.session(), 392 connection.webletChannel())); 393 // Alternative to sending Close (see above): 394 // channel.respond(new SimpleConsoleCommand("reload")); 395 } 396 } 397 398 private void attemptLocalLogin(NotifyConletModel event, 399 ConsoleConnection connection, AccountModel model) { 400 var bundle = resourceBundle(connection.locale()); 401 String userName = event.params().asString(0); 402 if (userName == null || userName.isEmpty()) { 403 connection.respond(new NotifyConletView(type(), 404 model.getConletId(), "setMessages", 405 null, bundle.getString("emptyUserName"))); 406 return; 407 } 408 var userData = users.get(userName); 409 String password = event.params().asString(1); 410 if (userData == null 411 || !BCrypt.verifyer().verify(password.getBytes(), 412 userData.get("password").getBytes()).verified) { 413 connection.respond(new NotifyConletView(type(), 414 model.getConletId(), "setMessages", 415 null, bundle.getString("invalidCredentials"))); 416 return; 417 } 418 Subject subject = new Subject(); 419 var user = new ConsoleUser(userName, 420 Optional.ofNullable(userData.get("fullName")).orElse(userName)); 421 if (userData.containsKey("email")) { 422 try { 423 user.setEmail( 424 new InternetAddress(userData.get("email"))); 425 } catch (AddressException e) { 426 logger.log(Level.WARNING, e, 427 () -> "Failed to parse email address \"" 428 + userData.get("email") + "\": " + e.getMessage()); 429 } 430 } 431 subject.getPrincipals().add(user); 432 fire(new UserAuthenticated(event.setAssociated(this, 433 new LoginContext(connection, model)), subject).by("Local Login")); 434 } 435 436 /** 437 * Invoked when the OIDC client has assembled the required information 438 * for contacting the provider. 439 * 440 * @param event the event 441 * @param channel the channel 442 */ 443 @Handler 444 public void onOpenLoginWindow(OpenLoginWindow event, Channel channel) { 445 event.forLogin().associated(this, LoginContext.class) 446 .ifPresent(ctx -> { 447 ctx.connection.respond(new NotifyConletView(type(), 448 ctx.model.getConletId(), "openLoginWindow", 449 event.uri().toString())); 450 }); 451 } 452 453 /** 454 * On oidc error. 455 * 456 * @param event the event 457 * @param channel the channel 458 */ 459 @Handler 460 public void onOidcError(OidcError event, Channel channel) { 461 var ctx 462 = event.event().associated(this, LoginContext.class).orElse(null); 463 if (ctx == null) { 464 return; 465 } 466 var connection = ctx.connection; 467 var msg = event.kind() == OidcError.Kind.ACCESS_DENIED 468 ? resourceBundle(connection.locale()).getString("accessDenied") 469 : resourceBundle(connection.locale()).getString("oidcError"); 470 connection.respond(new DisplayNotification("<span>" 471 + msg + "</span>").addOption("type", "Warning") 472 .addOption("autoClose", 5000)); 473 } 474 475 /** 476 * Invoked when a user has been authenticated. 477 * 478 * @param event the event 479 * @param channel the channel 480 */ 481 @Handler 482 public void onUserAuthenticated(UserAuthenticated event, Channel channel) { 483 var ctx = event.forLogin().associated(this, LoginContext.class) 484 .orElse(null); 485 if (ctx == null) { 486 return; 487 } 488 var connection = ctx.connection; 489 var model = ctx.model; 490 model.setDialogOpen(false); 491 connection.session().put(Subject.class, event.subject()); 492 connection.respond(new CloseModalDialog(type(), model.getConletId())); 493 connection 494 .associated(PENDING_CONSOLE_PREPARED, ConsolePrepared.class) 495 .ifPresentOrElse(ConsolePrepared::resumeHandling, 496 () -> connection 497 .respond(new SimpleConsoleCommand("reload"))); 498 } 499 500 /** 501 * Do set locale. 502 * 503 * @param event the event 504 * @param channel the channel 505 * @param conletId the conlet id 506 * @return true, if successful 507 * @throws Exception the exception 508 */ 509 @Override 510 protected boolean doSetLocale(SetLocale event, ConsoleConnection channel, 511 String conletId) throws Exception { 512 return stateFromSession(channel.session(), 513 type() + TYPE_INSTANCE_SEPARATOR + "Singleton") 514 .map(model -> !model.isDialogOpen()).orElse(true); 515 } 516 517 /** 518 * The context to preserve during the authentication process. 519 */ 520 private class LoginContext { 521 public final ConsoleConnection connection; 522 public final AccountModel model; 523 524 /** 525 * Instantiates a new oidc context. 526 * 527 * @param connection the connection 528 * @param model the model 529 */ 530 public LoginContext(ConsoleConnection connection, AccountModel model) { 531 this.connection = connection; 532 this.model = model; 533 } 534 } 535 536 /** 537 * Model with account info. 538 */ 539 public static class AccountModel extends ConletBaseModel { 540 541 private boolean dialogOpen; 542 543 /** 544 * Creates a new model with the given type and id. 545 * 546 * @param conletId the conlet id 547 */ 548 @ConstructorProperties({ "model" }) 549 public AccountModel(String conletId) { 550 super(conletId); 551 } 552 553 /** 554 * Checks if is dialog open. 555 * 556 * @return true, if is dialog open 557 */ 558 public boolean isDialogOpen() { 559 return dialogOpen; 560 } 561 562 /** 563 * Sets the dialog open. 564 * 565 * @param dialogOpen the new dialog open 566 */ 567 public void setDialogOpen(boolean dialogOpen) { 568 this.dialogOpen = dialogOpen; 569 } 570 } 571 572}