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}