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