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}