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