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.EnumSet; 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 */ 083public class LoginConlet extends FreeMarkerConlet<LoginConlet.AccountModel> { 084 085 private static final String PENDING_CONSOLE_PREPARED 086 = "pendingConsolePrepared"; 087 private final Map<String, Map<String, String>> users 088 = new ConcurrentHashMap<>(); 089 090 /** 091 * Creates a new component with its channel set to the given channel. 092 * 093 * @param componentChannel the channel that the component's handlers listen 094 * on by default and that {@link Manager#fire(Event, Channel...)} 095 * sends the event to 096 */ 097 public LoginConlet(Channel componentChannel) { 098 super(componentChannel); 099 } 100 101 @Override 102 protected String generateInstanceId(AddConletRequest event, 103 ConsoleConnection session) { 104 return "Singleton"; 105 } 106 107 /** 108 * Register conlet. 109 * 110 * @param event the event 111 * @param channel the channel 112 * @throws TemplateNotFoundException the template not found exception 113 * @throws MalformedTemplateNameException the malformed template name exception 114 * @throws ParseException the parse exception 115 * @throws IOException Signals that an I/O exception has occurred. 116 */ 117 @Handler 118 public void onConsoleReady(ConsoleReady event, ConsoleConnection channel) 119 throws TemplateNotFoundException, MalformedTemplateNameException, 120 ParseException, IOException { 121 // Add conlet resources to page 122 channel.respond(new AddConletType(type()) 123 .addScript(new ScriptResource() 124 .setScriptUri(event.renderSupport().conletResource( 125 type(), "Login-functions.js")) 126 .setScriptType("module")) 127 .addCss(event.renderSupport(), WebConsoleUtils.uriFromPath( 128 "Login-style.css")) 129 .addPageContent("headerIcons", Map.of("priority", "1000")) 130 .addRenderMode(RenderMode.Content)); 131 } 132 133 /** 134 * As a model has already been created in {@link #doUpdateConletState}, 135 * the "new" model may already exist in the session. 136 */ 137 @Override 138 protected Optional<AccountModel> createNewState(AddConletRequest event, 139 ConsoleConnection session, String conletId) throws Exception { 140 Optional<AccountModel> model 141 = stateFromSession(session.session(), conletId); 142 if (model.isPresent()) { 143 return model; 144 } 145 return super.createNewState(event, session, conletId); 146 } 147 148 @Override 149 protected Optional<AccountModel> createStateRepresentation(Event<?> event, 150 ConsoleConnection channel, String conletId) throws IOException { 151 return Optional.of(new AccountModel(conletId)); 152 } 153 154 /** 155 * The component can be configured with events that include 156 * a path (see @link {@link ConfigurationUpdate#paths()}) 157 * that matches this components path (see {@link Manager#componentPath()}). 158 * 159 * The following properties are recognized: 160 * 161 * `users` 162 * : See {@link LoginConlet}. 163 * 164 * @param event the event 165 */ 166 @SuppressWarnings("unchecked") 167 @Handler 168 public void onConfigUpdate(ConfigurationUpdate event) { 169 event.structured(componentPath()) 170 .map(c -> (Map<String, Map<String, String>>) c.get("users")) 171 .map(Map::entrySet).orElse(Collections.emptySet()).stream() 172 .forEach(e -> { 173 var user = users.computeIfAbsent(e.getKey(), 174 k -> new ConcurrentHashMap<>()); 175 user.putAll(e.getValue()); 176 }); 177 } 178 179 /** 180 * Handle web console page loaded. 181 * 182 * @param event the event 183 * @param channel the channel 184 * @throws IOException 185 * @throws ParseException 186 * @throws MalformedTemplateNameException 187 * @throws TemplateNotFoundException 188 */ 189 @Handler(priority = 1000) 190 public void onConsolePrepared(ConsolePrepared event, 191 ConsoleConnection channel) 192 throws TemplateNotFoundException, MalformedTemplateNameException, 193 ParseException, IOException { 194 // If we are logged in, proceed 195 if (channel.session().containsKey(Subject.class)) { 196 return; 197 } 198 199 // Suspend handling and save event "in" channel. 200 event.suspendHandling(); 201 channel.setAssociated(PENDING_CONSOLE_PREPARED, event); 202 203 // Create model and save in session. 204 String conletId = type() + TYPE_INSTANCE_SEPARATOR + "Singleton"; 205 AccountModel accountModel = new AccountModel(conletId); 206 accountModel.setDialogOpen(true); 207 putInSession(channel.session(), conletId, accountModel); 208 209 // Render login dialog 210 Template tpl = freemarkerConfig().getTemplate("Login-dialog.ftl.html"); 211 var bundle = resourceBundle(channel.locale()); 212 channel.respond(new OpenModalDialog(type(), conletId, 213 processTemplate(event, tpl, 214 fmSessionModel(channel.session()))) 215 .addOption("title", bundle.getString("title")) 216 .addOption("cancelable", false).addOption("okayLabel", "") 217 .addOption("applyLabel", bundle.getString("Submit")) 218 .addOption("useSubmit", true)); 219 } 220 221 @Override 222 protected Set<RenderMode> doRenderConlet(RenderConletRequestBase<?> event, 223 ConsoleConnection channel, String conletId, 224 AccountModel model) throws Exception { 225 Set<RenderMode> renderedAs = EnumSet.noneOf(RenderMode.class); 226 if (event.renderAs().contains(RenderMode.Content)) { 227 Template tpl 228 = freemarkerConfig().getTemplate("Login-status.ftl.html"); 229 channel.respond(new RenderConlet(type(), conletId, 230 processTemplate(event, tpl, 231 fmModel(event, channel, conletId, model))) 232 .setRenderAs(RenderMode.Content)); 233 channel.respond(new NotifyConletView(type(), conletId, 234 "updateUser", 235 WebConsoleUtils.userFromSession(channel.session()) 236 .map(ConsoleUser::getDisplayName).orElse(null))); 237 renderedAs.add(RenderMode.Content); 238 } 239 return renderedAs; 240 } 241 242 @Override 243 @SuppressWarnings("PMD.AvoidLiteralsInIfCondition") 244 protected void doUpdateConletState(NotifyConletModel event, 245 ConsoleConnection connection, AccountModel model) throws Exception { 246 var bundle = resourceBundle(connection.locale()); 247 if ("loginData".equals(event.method())) { 248 String userName = event.param(0); 249 if (userName == null || userName.isEmpty()) { 250 connection.respond(new NotifyConletView(type(), 251 model.getConletId(), "setMessages", 252 null, bundle.getString("emptyUserName"))); 253 return; 254 } 255 var userData = users.get(userName); 256 String password = event.param(1); 257 if (userData == null 258 || !BCrypt.verifyer().verify(password.getBytes(), 259 userData.get("password").getBytes()).verified) { 260 connection.respond(new NotifyConletView(type(), 261 model.getConletId(), "setMessages", 262 null, bundle.getString("invalidCredentials"))); 263 return; 264 } 265 Subject subject = new Subject(); 266 subject.getPrincipals().add(new ConsoleUser(userName, 267 Optional.ofNullable(userData.get("fullName")) 268 .orElse(userName))); 269 fire(new UserAuthenticated(event.setAssociated(this, 270 new LoginContext(connection, model)), subject) 271 .by("Local Login")); 272 return; 273 } 274 if ("logout".equals(event.method())) { 275 Optional.ofNullable((Subject) connection.session() 276 .get(Subject.class)).map(UserLoggedOut::new).map(this::fire); 277 connection.responsePipeline() 278 .fire(new Close(), connection.upstreamChannel()).get(); 279 connection.close(); 280 connection.respond(new DiscardSession(connection.session(), 281 connection.webletChannel())); 282 // Alternative to sending Close (see above): 283 // channel.respond(new SimpleConsoleCommand("reload")); 284 } 285 } 286 287 /** 288 * Invoked when a user has been authenticated. 289 * 290 * @param event the event 291 * @param channel the channel 292 */ 293 @Handler 294 public void onUserAuthenticated(UserAuthenticated event, Channel channel) { 295 @SuppressWarnings("PMD.CompareObjectsWithEquals") 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 final class LoginContext { 324 private final ConsoleConnection connection; 325 private final AccountModel model; 326 327 /** 328 * Instantiates a new oidc context. 329 * 330 * @param connection the connection 331 * @param model the model 332 */ 333 private 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 private 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}