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}