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.EnumSet; 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.CouplingBetweenObjects", "PMD.ExcessiveImports" }) 153public class LoginConlet extends FreeMarkerConlet<LoginConlet.AccountModel> { 154 155 private static final String PENDING_CONSOLE_PREPARED 156 = "pendingConsolePrepared"; 157 private Map<String, OidcProviderData> providers 158 = new ConcurrentHashMap<>(); 159 private Map<String, Map<String, String>> users 160 = new ConcurrentHashMap<>(); 161 162 /** 163 * Creates a new component with its channel set to the given channel. 164 * 165 * @param componentChannel the channel that the component's handlers listen 166 * on by default and that {@link Manager#fire(Event, Channel...)} 167 * sends the event to 168 */ 169 public LoginConlet(Channel componentChannel) { 170 super(componentChannel); 171 } 172 173 @Override 174 protected String generateInstanceId(AddConletRequest event, 175 ConsoleConnection session) { 176 return "Singleton"; 177 } 178 179 /** 180 * Register conlet. 181 * 182 * @param event the event 183 * @param channel the channel 184 * @throws TemplateNotFoundException the template not found exception 185 * @throws MalformedTemplateNameException the malformed template name exception 186 * @throws ParseException the parse exception 187 * @throws IOException Signals that an I/O exception has occurred. 188 */ 189 @Handler 190 public void onConsoleReady(ConsoleReady event, ConsoleConnection channel) 191 throws TemplateNotFoundException, MalformedTemplateNameException, 192 ParseException, IOException { 193 // Define providers 194 StringBuffer script = new StringBuffer(1000); 195 script.append("window.orgJGrapesOidcLogin" 196 + " = window.orgJGrapesOidcLogin || {};" 197 + "window.orgJGrapesOidcLogin.providers = [];"); 198 boolean first = true; 199 for (var e : providers.entrySet()) { 200 if (first) { 201 script.append("let "); 202 first = false; 203 } 204 script.append("provider = new Map();" 205 + "window.orgJGrapesOidcLogin.providers.push(provider);" 206 + "provider.set('name', '").append(e.getKey()) 207 .append("');provider.set('displayName', '") 208 .append(e.getValue().displayName()).append("');"); 209 if (e.getValue().popup() != null) { 210 for (var d : e.getValue().popup().entrySet()) { 211 String key = "popup" + d.getKey().substring(0, 1) 212 .toUpperCase() + d.getKey().substring(1); 213 script.append("provider.set('").append(key).append("', '") 214 .append(d.getValue()).append("');"); 215 } 216 } 217 } 218 var providerDefs = script.toString(); 219 // Add conlet resources to page 220 channel.respond(new AddConletType(type()) 221 .addScript(new ScriptResource() 222 .setScriptUri(event.renderSupport().conletResource( 223 type(), "Login-functions.js")) 224 .setScriptType("module")) 225 .addScript(new ScriptResource().setScriptSource(providerDefs)) 226 .addCss(event.renderSupport(), WebConsoleUtils.uriFromPath( 227 "Login-style.css")) 228 .addPageContent("headerIcons", Map.of("priority", "1000")) 229 .addRenderMode(RenderMode.Content)); 230 } 231 232 /** 233 * As a model has already been created in {@link #doUpdateConletState}, 234 * the "new" model may already exist in the session. 235 */ 236 @Override 237 protected Optional<AccountModel> createNewState(AddConletRequest event, 238 ConsoleConnection session, String conletId) throws Exception { 239 Optional<AccountModel> model 240 = stateFromSession(session.session(), conletId); 241 if (model.isPresent()) { 242 return model; 243 } 244 return super.createNewState(event, session, conletId); 245 } 246 247 @Override 248 protected Optional<AccountModel> createStateRepresentation(Event<?> event, 249 ConsoleConnection channel, String conletId) throws IOException { 250 return Optional.of(new AccountModel(conletId)); 251 } 252 253 /** 254 * The component can be configured with events that include 255 * a path (see @link {@link ConfigurationUpdate#paths()}) 256 * that matches this components path (see {@link Manager#componentPath()}). 257 * 258 * The following properties are recognized: 259 * 260 * `users` 261 * : See {@link LoginConlet}. 262 * 263 * @param event the event 264 */ 265 @SuppressWarnings("unchecked") 266 @Handler 267 public void onConfigUpdate(ConfigurationUpdate event) { 268 users = event.structured(componentPath()) 269 .map(c -> (List<Map<String, String>>) c.get("users")) 270 .orElseGet(Collections::emptyList).stream() 271 .collect(Collectors.toMap(e -> e.get("name"), Function.identity())); 272 ObjectMapper mapper = new ObjectMapper(); 273 providers = event.structured(componentPath()) 274 .map(c -> (List<Map<String, String>>) c.get("oidcProviders")) 275 .orElseGet(Collections::emptyList).stream() 276 .map(e -> mapper.convertValue(e, OidcProviderData.class)) 277 .collect( 278 Collectors.toMap(OidcProviderData::name, Function.identity())); 279 } 280 281 /** 282 * Handle web console page loaded. 283 * 284 * @param event the event 285 * @param channel the channel 286 * @throws IOException 287 * @throws ParseException 288 * @throws MalformedTemplateNameException 289 * @throws TemplateNotFoundException 290 */ 291 @Handler(priority = 1000) 292 public void onConsolePrepared(ConsolePrepared event, 293 ConsoleConnection channel) 294 throws TemplateNotFoundException, MalformedTemplateNameException, 295 ParseException, IOException { 296 // If we are logged in, proceed 297 if (channel.session().containsKey(Subject.class)) { 298 return; 299 } 300 301 // Suspend handling and save event "in" channel. 302 event.suspendHandling(); 303 channel.setAssociated(PENDING_CONSOLE_PREPARED, event); 304 305 // Create model and save in session. 306 String conletId = type() + TYPE_INSTANCE_SEPARATOR + "Singleton"; 307 AccountModel accountModel = new AccountModel(conletId); 308 accountModel.setDialogOpen(true); 309 putInSession(channel.session(), conletId, accountModel); 310 311 // Render login dialog 312 var fmModel = fmSessionModel(channel.session()); 313 fmModel.put("hasUser", !users.isEmpty()); 314 fmModel.put("providers", providers); 315 Template tpl = freemarkerConfig().getTemplate("Login-dialog.ftl.html"); 316 var bundle = resourceBundle(channel.locale()); 317 channel.respond(new OpenModalDialog(type(), conletId, 318 processTemplate(event, tpl, fmModel)) 319 .addOption("title", bundle.getString("title")) 320 .addOption("cancelable", false) 321 .addOption("applyLabel", 322 providers.isEmpty() ? bundle.getString("Log in") : "") 323 .addOption("useSubmit", true)); 324 } 325 326 @Override 327 protected Set<RenderMode> doRenderConlet(RenderConletRequestBase<?> event, 328 ConsoleConnection channel, String conletId, 329 AccountModel model) throws Exception { 330 Set<RenderMode> renderedAs = EnumSet.noneOf(RenderMode.class); 331 if (event.renderAs().contains(RenderMode.Content)) { 332 Template tpl 333 = freemarkerConfig().getTemplate("Login-status.ftl.html"); 334 channel.respond(new RenderConlet(type(), conletId, 335 processTemplate(event, tpl, 336 fmModel(event, channel, conletId, model))) 337 .setRenderAs(RenderMode.Content)); 338 channel.respond(new NotifyConletView(type(), conletId, 339 "updateUser", 340 WebConsoleUtils.userFromSession(channel.session()) 341 .map(ConsoleUser::getDisplayName).orElse(null))); 342 renderedAs.add(RenderMode.Content); 343 } 344 return renderedAs; 345 } 346 347 @Override 348 @SuppressWarnings({ "PMD.AvoidLiteralsInIfCondition" }) 349 protected void doUpdateConletState(NotifyConletModel event, 350 ConsoleConnection connection, AccountModel model) throws Exception { 351 if ("loginData".equals(event.method())) { 352 attemptLocalLogin(event, connection, model); 353 return; 354 } 355 if ("useProvider".equals(event.method())) { 356 var locales = Optional.ofNullable( 357 (Selection) connection.session().get(Selection.class)) 358 .map(Selection::get).orElse(new Locale[0]); 359 fire(new StartOidcLogin(providers.get((String) event.params()[0]), 360 locales).setAssociated(this, 361 new LoginContext(connection, model))); 362 return; 363 } 364 if ("logout".equals(event.method())) { 365 Optional.ofNullable((Subject) connection.session() 366 .get(Subject.class)).map(UserLoggedOut::new).map(this::fire); 367 connection.responsePipeline() 368 .fire(new Close(), connection.upstreamChannel()).get(); 369 connection.close(); 370 connection.respond(new DiscardSession(connection.session(), 371 connection.webletChannel())); 372 // Alternative to sending Close (see above): 373 // channel.respond(new SimpleConsoleCommand("reload")); 374 } 375 } 376 377 private void attemptLocalLogin(NotifyConletModel event, 378 ConsoleConnection connection, AccountModel model) { 379 var bundle = resourceBundle(connection.locale()); 380 String userName = (String) event.param(0); 381 if (userName == null || userName.isEmpty()) { 382 connection.respond(new NotifyConletView(type(), 383 model.getConletId(), "setMessages", 384 null, bundle.getString("emptyUserName"))); 385 return; 386 } 387 var userData = users.get(userName); 388 String password = event.param(1); 389 if (userData == null 390 || !BCrypt.verifyer().verify(password.getBytes(), 391 userData.get("password").getBytes()).verified) { 392 connection.respond(new NotifyConletView(type(), 393 model.getConletId(), "setMessages", 394 null, bundle.getString("invalidCredentials"))); 395 return; 396 } 397 Subject subject = new Subject(); 398 var user = new ConsoleUser(userName, 399 Optional.ofNullable(userData.get("fullName")).orElse(userName)); 400 if (userData.containsKey("email")) { 401 try { 402 user.setEmail( 403 new InternetAddress(userData.get("email"))); 404 } catch (AddressException e) { 405 logger.log(Level.WARNING, e, 406 () -> "Failed to parse email address \"" 407 + userData.get("email") + "\": " + e.getMessage()); 408 } 409 } 410 subject.getPrincipals().add(user); 411 fire(new UserAuthenticated(event.setAssociated(this, 412 new LoginContext(connection, model)), subject).by("Local Login")); 413 } 414 415 /** 416 * Invoked when the OIDC client has assembled the required information 417 * for contacting the provider. 418 * 419 * @param event the event 420 * @param channel the channel 421 */ 422 @Handler 423 public void onOpenLoginWindow(OpenLoginWindow event, Channel channel) { 424 event.forLogin().associated(this, LoginContext.class) 425 .ifPresent(ctx -> { 426 ctx.connection.respond(new NotifyConletView(type(), 427 ctx.model.getConletId(), "openLoginWindow", 428 event.uri().toString())); 429 }); 430 } 431 432 /** 433 * On oidc error. 434 * 435 * @param event the event 436 * @param channel the channel 437 */ 438 @Handler 439 public void onOidcError(OidcError event, Channel channel) { 440 var ctx 441 = event.event().associated(this, LoginContext.class).orElse(null); 442 if (ctx == null) { 443 return; 444 } 445 var connection = ctx.connection; 446 String msg; 447 switch (event.kind()) { 448 case ACCESS_DENIED: 449 msg = resourceBundle(connection.locale()).getString("accessDenied"); 450 break; 451 case INTERNAL_SERVER_ERROR: 452 msg = resourceBundle(connection.locale()).getString("serverError"); 453 break; 454 default: 455 msg = resourceBundle(connection.locale()).getString("oidcError"); 456 break; 457 } 458 connection.respond(new DisplayNotification("<span>" 459 + msg + "</span>").addOption("type", "Warning") 460 .addOption("autoClose", 5000)); 461 } 462 463 /** 464 * Invoked when a user has been authenticated. 465 * 466 * @param event the event 467 * @param channel the channel 468 */ 469 @Handler 470 public void onUserAuthenticated(UserAuthenticated event, Channel channel) { 471 var ctx = event.forLogin().associated(this, LoginContext.class) 472 .orElse(null); 473 if (ctx == null) { 474 return; 475 } 476 var connection = ctx.connection; 477 var model = ctx.model; 478 model.setDialogOpen(false); 479 connection.session().put(Subject.class, event.subject()); 480 connection.respond(new CloseModalDialog(type(), model.getConletId())); 481 connection 482 .associated(PENDING_CONSOLE_PREPARED, ConsolePrepared.class) 483 .ifPresentOrElse(ConsolePrepared::resumeHandling, 484 () -> connection 485 .respond(new SimpleConsoleCommand("reload"))); 486 } 487 488 /** 489 * Do set locale. 490 * 491 * @param event the event 492 * @param channel the channel 493 * @param conletId the conlet id 494 * @return true, if successful 495 * @throws Exception the exception 496 */ 497 @Override 498 protected boolean doSetLocale(SetLocale event, ConsoleConnection channel, 499 String conletId) throws Exception { 500 return stateFromSession(channel.session(), 501 type() + TYPE_INSTANCE_SEPARATOR + "Singleton") 502 .map(model -> !model.isDialogOpen()).orElse(true); 503 } 504 505 /** 506 * The context to preserve during the authentication process. 507 */ 508 private final class LoginContext { 509 private final ConsoleConnection connection; 510 private final AccountModel model; 511 512 /** 513 * Instantiates a new oidc context. 514 * 515 * @param connection the connection 516 * @param model the model 517 */ 518 private LoginContext(ConsoleConnection connection, AccountModel model) { 519 this.connection = connection; 520 this.model = model; 521 } 522 } 523 524 /** 525 * Model with account info. 526 */ 527 public static class AccountModel extends ConletBaseModel { 528 529 private boolean dialogOpen; 530 531 /** 532 * Creates a new model with the given type and id. 533 * 534 * @param conletId the conlet id 535 */ 536 @ConstructorProperties({ "model" }) 537 public AccountModel(String conletId) { 538 super(conletId); 539 } 540 541 /** 542 * Checks if is dialog open. 543 * 544 * @return true, if is dialog open 545 */ 546 public boolean isDialogOpen() { 547 return dialogOpen; 548 } 549 550 /** 551 * Sets the dialog open. 552 * 553 * @param dialogOpen the new dialog open 554 */ 555 public void setDialogOpen(boolean dialogOpen) { 556 this.dialogOpen = dialogOpen; 557 } 558 } 559 560}