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.locallogin; 020 021import at.favre.lib.crypto.bcrypt.BCrypt; 022import freemarker.core.ParseException; 023import freemarker.template.MalformedTemplateNameException; 024import freemarker.template.Template; 025import freemarker.template.TemplateException; 026import freemarker.template.TemplateNotFoundException; 027import java.beans.ConstructorProperties; 028import java.io.IOException; 029import java.io.StringWriter; 030import java.util.Collections; 031import java.util.HashSet; 032import java.util.Map; 033import java.util.Optional; 034import java.util.Set; 035import java.util.concurrent.ConcurrentHashMap; 036import java.util.concurrent.Future; 037import javax.security.auth.Subject; 038import org.jgrapes.core.Channel; 039import org.jgrapes.core.Components; 040import org.jgrapes.core.Event; 041import org.jgrapes.core.EventPipeline; 042import org.jgrapes.core.Manager; 043import org.jgrapes.core.annotation.Handler; 044import org.jgrapes.http.events.DiscardSession; 045import org.jgrapes.io.events.Close; 046import org.jgrapes.util.events.ConfigurationUpdate; 047import org.jgrapes.webconsole.base.Conlet.RenderMode; 048import org.jgrapes.webconsole.base.ConletBaseModel; 049import org.jgrapes.webconsole.base.ConsoleConnection; 050import org.jgrapes.webconsole.base.ConsoleUser; 051import org.jgrapes.webconsole.base.WebConsoleUtils; 052import org.jgrapes.webconsole.base.events.AddConletRequest; 053import org.jgrapes.webconsole.base.events.AddConletType; 054import org.jgrapes.webconsole.base.events.AddPageResources.ScriptResource; 055import org.jgrapes.webconsole.base.events.CloseModalDialog; 056import org.jgrapes.webconsole.base.events.ConsolePrepared; 057import org.jgrapes.webconsole.base.events.ConsoleReady; 058import org.jgrapes.webconsole.base.events.NotifyConletModel; 059import org.jgrapes.webconsole.base.events.NotifyConletView; 060import org.jgrapes.webconsole.base.events.OpenModalDialog; 061import org.jgrapes.webconsole.base.events.RenderConlet; 062import org.jgrapes.webconsole.base.events.RenderConletRequestBase; 063import org.jgrapes.webconsole.base.events.SetLocale; 064import org.jgrapes.webconsole.base.events.SimpleConsoleCommand; 065import org.jgrapes.webconsole.base.events.UserAuthenticated; 066import org.jgrapes.webconsole.base.events.UserLoggedOut; 067import org.jgrapes.webconsole.base.freemarker.FreeMarkerConlet; 068 069/** 070 * As simple login conlet for password based logins. The users 071 * are configured as property "users" of the conlet: 072 * ```yaml 073 * "...": 074 * "/LoginConlet": 075 * users: 076 * admin: 077 * # Full name is optional 078 * fullName: Administrator 079 * password: "$2b$05$NiBd74ZGdplLC63ePZf1f.UtjMKkbQ23cQoO2OKOFalDBHWAOy21." 080 * test: 081 * fullName: Test Account 082 * password: "$2b$05$hZaI/jToXf/d3BctZdT38Or7H7h6Pn2W3WiB49p5AyhDHFkkYCvo2" 083 * 084 * ``` 085 * 086 * Passwords are hashed using bcrypt. 087 */ 088@SuppressWarnings("PMD.DataflowAnomalyAnalysis") 089public class LoginConlet extends FreeMarkerConlet<LoginConlet.AccountModel> { 090 091 private static final String PENDING_CONSOLE_PREPARED 092 = "pendingConsolePrepared"; 093 private final Map<String, Map<String, String>> users 094 = new ConcurrentHashMap<>(); 095 096 /** 097 * Creates a new component with its channel set to the given channel. 098 * 099 * @param componentChannel the channel that the component's handlers listen 100 * on by default and that {@link Manager#fire(Event, Channel...)} 101 * sends the event to 102 */ 103 public LoginConlet(Channel componentChannel) { 104 super(componentChannel); 105 } 106 107 @Override 108 protected String generateInstanceId(AddConletRequest event, 109 ConsoleConnection session) { 110 return "Singleton"; 111 } 112 113 /** 114 * Register conlet. 115 * 116 * @param event the event 117 * @param channel the channel 118 * @throws TemplateNotFoundException the template not found exception 119 * @throws MalformedTemplateNameException the malformed template name exception 120 * @throws ParseException the parse exception 121 * @throws IOException Signals that an I/O exception has occurred. 122 */ 123 @Handler 124 public void onConsoleReady(ConsoleReady event, ConsoleConnection channel) 125 throws TemplateNotFoundException, MalformedTemplateNameException, 126 ParseException, IOException { 127 // Add conlet resources to page 128 channel.respond(new AddConletType(type()) 129 .addScript(new ScriptResource() 130 .setScriptUri(event.renderSupport().conletResource( 131 type(), "Login-functions.js")) 132 .setScriptType("module")) 133 .addCss(event.renderSupport(), WebConsoleUtils.uriFromPath( 134 "Login-style.css")) 135 .addPageContent("headerIcons", Map.of("priority", "1000")) 136 .addRenderMode(RenderMode.Content)); 137 } 138 139 /** 140 * As a model has already been created in {@link #doUpdateConletState}, 141 * the "new" model may already exist in the session. 142 */ 143 @Override 144 protected Optional<AccountModel> createNewState(AddConletRequest event, 145 ConsoleConnection session, String conletId) throws Exception { 146 Optional<AccountModel> model 147 = stateFromSession(session.session(), conletId); 148 if (model.isPresent()) { 149 return model; 150 } 151 return super.createNewState(event, session, conletId); 152 } 153 154 @Override 155 protected Optional<AccountModel> createStateRepresentation(Event<?> event, 156 ConsoleConnection channel, String conletId) throws IOException { 157 return Optional.of(new AccountModel(conletId)); 158 } 159 160 /** 161 * The component can be configured with events that include 162 * a path (see @link {@link ConfigurationUpdate#paths()}) 163 * that matches this components path (see {@link Manager#componentPath()}). 164 * 165 * The following properties are recognized: 166 * 167 * `users` 168 * : See {@link LoginConlet}. 169 * 170 * @param event the event 171 */ 172 @SuppressWarnings("unchecked") 173 @Handler 174 public void onConfigUpdate(ConfigurationUpdate event) { 175 event.structured(componentPath()) 176 .map(c -> (Map<String, Map<String, String>>) c.get("users")) 177 .map(Map::entrySet).orElse(Collections.emptySet()).stream() 178 .forEach(e -> { 179 var user = users.computeIfAbsent(e.getKey(), 180 k -> new ConcurrentHashMap<>()); 181 user.putAll(e.getValue()); 182 }); 183 } 184 185 /** 186 * Handle web console page loaded. 187 * 188 * @param event the event 189 * @param channel the channel 190 * @throws IOException 191 * @throws ParseException 192 * @throws MalformedTemplateNameException 193 * @throws TemplateNotFoundException 194 */ 195 @Handler(priority = 1000) 196 public void onConsolePrepared(ConsolePrepared event, 197 ConsoleConnection channel) 198 throws TemplateNotFoundException, MalformedTemplateNameException, 199 ParseException, IOException { 200 // If we are logged in, proceed 201 if (channel.session().containsKey(Subject.class)) { 202 return; 203 } 204 205 // Suspend handling and save event "in" channel. 206 event.suspendHandling(); 207 channel.setAssociated(PENDING_CONSOLE_PREPARED, event); 208 209 // Create model and save in session. 210 String conletId = type() + TYPE_INSTANCE_SEPARATOR + "Singleton"; 211 AccountModel accountModel = new AccountModel(conletId); 212 accountModel.setDialogOpen(true); 213 putInSession(channel.session(), conletId, accountModel); 214 215 // Render login dialog 216 Template tpl = freemarkerConfig().getTemplate("Login-dialog.ftl.html"); 217 var bundle = resourceBundle(channel.locale()); 218 channel.respond(new OpenModalDialog(type(), conletId, 219 processTemplate(event, tpl, 220 fmSessionModel(channel.session()))) 221 .addOption("title", bundle.getString("title")) 222 .addOption("cancelable", false).addOption("okayLabel", "") 223 .addOption("applyLabel", bundle.getString("Submit")) 224 .addOption("useSubmit", true)); 225 } 226 227 private Future<String> processTemplate( 228 Event<?> request, Template template, 229 Object dataModel) { 230 return request.processedBy().map(EventPipeline::executorService) 231 .orElse(Components.defaultExecutorService()).submit(() -> { 232 StringWriter out = new StringWriter(); 233 try { 234 template.process(dataModel, out); 235 } catch (TemplateException | IOException e) { 236 throw new IllegalArgumentException(e); 237 } 238 return out.toString(); 239 240 }); 241 } 242 243 @Override 244 protected Set<RenderMode> doRenderConlet(RenderConletRequestBase<?> event, 245 ConsoleConnection channel, String conletId, 246 AccountModel model) throws Exception { 247 Set<RenderMode> renderedAs = new HashSet<>(); 248 if (event.renderAs().contains(RenderMode.Content)) { 249 Template tpl 250 = freemarkerConfig().getTemplate("Login-status.ftl.html"); 251 channel.respond(new RenderConlet(type(), conletId, 252 processTemplate(event, tpl, 253 fmModel(event, channel, conletId, model))) 254 .setRenderAs(RenderMode.Content)); 255 channel.respond(new NotifyConletView(type(), conletId, 256 "updateUser", 257 WebConsoleUtils.userFromSession(channel.session()) 258 .map(ConsoleUser::getDisplayName).orElse(null))); 259 renderedAs.add(RenderMode.Content); 260 } 261 return renderedAs; 262 } 263 264 @Override 265 @SuppressWarnings("PMD.AvoidLiteralsInIfCondition") 266 protected void doUpdateConletState(NotifyConletModel event, 267 ConsoleConnection connection, AccountModel model) throws Exception { 268 var bundle = resourceBundle(connection.locale()); 269 if ("loginData".equals(event.method())) { 270 String userName = event.params().asString(0); 271 if (userName == null || userName.isEmpty()) { 272 connection.respond(new NotifyConletView(type(), 273 model.getConletId(), "setMessages", 274 null, bundle.getString("emptyUserName"))); 275 return; 276 } 277 var userData = users.get(userName); 278 String password = event.params().asString(1); 279 if (userData == null 280 || !BCrypt.verifyer().verify(password.getBytes(), 281 userData.get("password").getBytes()).verified) { 282 connection.respond(new NotifyConletView(type(), 283 model.getConletId(), "setMessages", 284 null, bundle.getString("invalidCredentials"))); 285 return; 286 } 287 Subject subject = new Subject(); 288 subject.getPrincipals().add(new ConsoleUser(userName, 289 Optional.ofNullable(userData.get("fullName")) 290 .orElse(userName))); 291 fire(new UserAuthenticated(event.setAssociated(this, 292 new LoginContext(connection, model)), subject) 293 .by("Local Login")); 294 return; 295 } 296 if ("logout".equals(event.method())) { 297 Optional.ofNullable((Subject) connection.session() 298 .get(Subject.class)).map(UserLoggedOut::new).map(this::fire); 299 connection.responsePipeline() 300 .fire(new Close(), connection.upstreamChannel()).get(); 301 connection.close(); 302 connection.respond(new DiscardSession(connection.session(), 303 connection.webletChannel())); 304 // Alternative to sending Close (see above): 305 // channel.respond(new SimpleConsoleCommand("reload")); 306 } 307 } 308 309 /** 310 * Invoked when a user has been authenticated. 311 * 312 * @param event the event 313 * @param channel the channel 314 */ 315 @Handler 316 public void onUserAuthenticated(UserAuthenticated event, Channel channel) { 317 var ctx = event.forLogin().associated(this, LoginContext.class) 318 .filter(c -> c.conlet() == this).orElse(null); 319 if (ctx == null) { 320 return; 321 } 322 var model = ctx.model; 323 model.setDialogOpen(false); 324 var connection = ctx.connection; 325 connection.session().put(Subject.class, event.subject()); 326 connection.respond(new CloseModalDialog(type(), model.getConletId())); 327 connection.associated(PENDING_CONSOLE_PREPARED, ConsolePrepared.class) 328 .ifPresentOrElse(ConsolePrepared::resumeHandling, 329 () -> connection 330 .respond(new SimpleConsoleCommand("reload"))); 331 } 332 333 @Override 334 protected boolean doSetLocale(SetLocale event, ConsoleConnection channel, 335 String conletId) throws Exception { 336 return stateFromSession(channel.session(), 337 type() + TYPE_INSTANCE_SEPARATOR + "Singleton") 338 .map(model -> !model.isDialogOpen()).orElse(true); 339 } 340 341 /** 342 * The context to preserve during the authentication process. 343 */ 344 private class LoginContext { 345 public final ConsoleConnection connection; 346 public final AccountModel model; 347 348 /** 349 * Instantiates a new oidc context. 350 * 351 * @param connection the connection 352 * @param model the model 353 */ 354 public LoginContext(ConsoleConnection connection, AccountModel model) { 355 this.connection = connection; 356 this.model = model; 357 } 358 359 /** 360 * Returns the conlet (the outer class). 361 * 362 * @return the login conlet 363 */ 364 public LoginConlet conlet() { 365 return LoginConlet.this; 366 } 367 } 368 369 /** 370 * Model with account info. 371 */ 372 public static class AccountModel extends ConletBaseModel { 373 374 private boolean dialogOpen; 375 376 /** 377 * Creates a new model with the given type and id. 378 * 379 * @param conletId the web console component id 380 */ 381 @ConstructorProperties({ "conletId" }) 382 public AccountModel(String conletId) { 383 super(conletId); 384 } 385 386 /** 387 * Checks if is dialog open. 388 * 389 * @return true, if is dialog open 390 */ 391 public boolean isDialogOpen() { 392 return dialogOpen; 393 } 394 395 /** 396 * Sets the dialog open. 397 * 398 * @param dialogOpen the new dialog open 399 */ 400 public void setDialogOpen(boolean dialogOpen) { 401 this.dialogOpen = dialogOpen; 402 } 403 404 } 405 406}