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.oidclogin;
020
021import at.favre.lib.crypto.bcrypt.BCrypt;
022import com.fasterxml.jackson.databind.ObjectMapper;
023import freemarker.core.ParseException;
024import freemarker.template.MalformedTemplateNameException;
025import freemarker.template.Template;
026import freemarker.template.TemplateException;
027import freemarker.template.TemplateNotFoundException;
028import jakarta.mail.internet.AddressException;
029import jakarta.mail.internet.InternetAddress;
030import java.beans.ConstructorProperties;
031import java.io.IOException;
032import java.io.StringWriter;
033import java.util.Collections;
034import java.util.HashSet;
035import java.util.List;
036import java.util.Locale;
037import java.util.Map;
038import java.util.Optional;
039import java.util.Set;
040import java.util.concurrent.ConcurrentHashMap;
041import java.util.concurrent.Future;
042import java.util.function.Function;
043import java.util.logging.Level;
044import java.util.stream.Collectors;
045import javax.security.auth.Subject;
046import org.jgrapes.core.Channel;
047import org.jgrapes.core.Components;
048import org.jgrapes.core.Event;
049import org.jgrapes.core.EventPipeline;
050import org.jgrapes.core.Manager;
051import org.jgrapes.core.annotation.Handler;
052import org.jgrapes.http.LanguageSelector.Selection;
053import org.jgrapes.http.events.DiscardSession;
054import org.jgrapes.io.events.Close;
055import org.jgrapes.util.events.ConfigurationUpdate;
056import org.jgrapes.webconsole.base.Conlet.RenderMode;
057import org.jgrapes.webconsole.base.ConletBaseModel;
058import org.jgrapes.webconsole.base.ConsoleConnection;
059import org.jgrapes.webconsole.base.ConsoleUser;
060import org.jgrapes.webconsole.base.WebConsoleUtils;
061import org.jgrapes.webconsole.base.events.AddConletRequest;
062import org.jgrapes.webconsole.base.events.AddConletType;
063import org.jgrapes.webconsole.base.events.AddPageResources.ScriptResource;
064import org.jgrapes.webconsole.base.events.CloseModalDialog;
065import org.jgrapes.webconsole.base.events.ConsolePrepared;
066import org.jgrapes.webconsole.base.events.ConsoleReady;
067import org.jgrapes.webconsole.base.events.DisplayNotification;
068import org.jgrapes.webconsole.base.events.NotifyConletModel;
069import org.jgrapes.webconsole.base.events.NotifyConletView;
070import org.jgrapes.webconsole.base.events.OpenModalDialog;
071import org.jgrapes.webconsole.base.events.RenderConlet;
072import org.jgrapes.webconsole.base.events.RenderConletRequestBase;
073import org.jgrapes.webconsole.base.events.SetLocale;
074import org.jgrapes.webconsole.base.events.SimpleConsoleCommand;
075import org.jgrapes.webconsole.base.events.UserAuthenticated;
076import org.jgrapes.webconsole.base.events.UserLoggedOut;
077import org.jgrapes.webconsole.base.freemarker.FreeMarkerConlet;
078
079/**
080 * A login conlet for OIDC based logins with a fallback to password 
081 * based logins.
082 * 
083 * OIDC providers can be configured as property "oidcProviders" of the conlet:
084 * ```yaml
085 * "...":
086 *   "/LoginConlet":
087 *     oidcProviders:
088 *     - name: my-provider
089 *       displayName: My Provider
090 *       configurationEndpoint: https://test.com/.well-known/openid-configuration
091 *       # If no configurationEndpoint is available, the authorization 
092 *       # endpointEndpoint and the tokenEndpoint may be configured instead
093 *       clientId: "WebConsoleTest"
094 *       secret: "(unknown)"
095 *       # The size of the popup window for the provider's dialog
096 *       popup:
097 *         # Size of the popup windows for authentication. Either
098 *         # relative to the browser window's size or absolute in pixels
099 *         factor: 0.6
100 *         # width: 1600
101 *         # height: 600
102 *       # Only users with one of the roles listed here are allowed to login.
103 *       # The check is performed against the roles reported by the provider
104 *       # before any role mappings are applied (see below).
105 *       # An empty role name in this list allows users without any role 
106 *       # to login.
107 *       authorizedRoles:
108 *       - "admin"
109 *       - "user"
110 *       - ""
111 *       # Mappings to be applied to the preferred user name reported
112 *       # by the provider. The list is evaluated up to the first match.
113 *       userMappings:
114 *       - from: "(.*)"
115 *         to: "$1@oidc"
116 *       # Mappings to be applied to the role names reported by the 
117 *       # provider. The list is evaluated up to the first match.
118 *       roleMappings:
119 *       - from: "(.*)"
120 *         to: "$1@oidc"
121 * ```
122 * 
123 * The user id of the authenticated user is taken from the ID token's
124 * claim `preferred_username`, the display name from the claim `name`.
125 * Roles are created from the ID token's claim `roles`. Reporting the
126 * latter has usually to be added in the provider's configuration. 
127 * Of course, roles can also be added independently based on the
128 * user id by using another component, thus separating the authentication
129 * by the OIDC provider from the role management. 
130 * 
131 * The component requires that an instance of {@link OidcClient}
132 * handles the {@link StartOidcLogin} events fired on the component's
133 * channel.
134 * 
135 * As a fallback, local users can be configured as property "users":
136 * ```yaml
137 * "...":
138 *   "/LoginConlet":
139 *     users:
140 *     - name: admin
141 *       # Full name is optional
142 *       fullName: Administrator
143 *       password: "$2b$05$NiBd74ZGdplLC63ePZf1f.UtjMKkbQ23cQoO2OKOFalDBHWAOy21."
144 *     - name: test
145 *       fullName: Test Account
146 *       email: test@test.com
147 *       password: "$2b$05$hZaI/jToXf/d3BctZdT38Or7H7h6Pn2W3WiB49p5AyhDHFkkYCvo2"
148 * ```
149 * 
150 * Passwords are hashed using bcrypt.
151 * 
152 * The local login part of the dialog is only shown if at least one user is
153 * configured.
154 */
155@SuppressWarnings({ "PMD.DataflowAnomalyAnalysis", "PMD.CouplingBetweenObjects",
156    "PMD.ExcessiveImports" })
157public class LoginConlet extends FreeMarkerConlet<LoginConlet.AccountModel> {
158
159    private static final String PENDING_CONSOLE_PREPARED
160        = "pendingConsolePrepared";
161    private Map<String, OidcProviderData> providers
162        = new ConcurrentHashMap<>();
163    private Map<String, Map<String, String>> users
164        = new ConcurrentHashMap<>();
165
166    /**
167     * Creates a new component with its channel set to the given channel.
168     * 
169     * @param componentChannel the channel that the component's handlers listen
170     *            on by default and that {@link Manager#fire(Event, Channel...)}
171     *            sends the event to
172     */
173    public LoginConlet(Channel componentChannel) {
174        super(componentChannel);
175    }
176
177    @Override
178    protected String generateInstanceId(AddConletRequest event,
179            ConsoleConnection session) {
180        return "Singleton";
181    }
182
183    /**
184     * Register conlet.
185     *
186     * @param event the event
187     * @param channel the channel
188     * @throws TemplateNotFoundException the template not found exception
189     * @throws MalformedTemplateNameException the malformed template name exception
190     * @throws ParseException the parse exception
191     * @throws IOException Signals that an I/O exception has occurred.
192     */
193    @Handler
194    public void onConsoleReady(ConsoleReady event, ConsoleConnection channel)
195            throws TemplateNotFoundException, MalformedTemplateNameException,
196            ParseException, IOException {
197        // Define providers
198        StringBuffer script = new StringBuffer(1000);
199        script.append("window.orgJGrapesOidcLogin"
200            + " = window.orgJGrapesOidcLogin || {};"
201            + "window.orgJGrapesOidcLogin.providers = [];");
202        boolean first = true;
203        for (var e : providers.entrySet()) {
204            if (first) {
205                script.append("let ");
206                first = false;
207            }
208            script.append("provider = new Map();"
209                + "window.orgJGrapesOidcLogin.providers.push(provider);"
210                + "provider.set('name', '").append(e.getKey())
211                .append("');provider.set('displayName', '")
212                .append(e.getValue().displayName()).append("');");
213            if (e.getValue().popup() != null) {
214                for (var d : e.getValue().popup().entrySet()) {
215                    String key = "popup" + d.getKey().substring(0, 1)
216                        .toUpperCase() + d.getKey().substring(1);
217                    script.append("provider.set('").append(key).append("', '")
218                        .append(d.getValue()).append("');");
219                }
220            }
221        }
222        var providerDefs = script.toString();
223        // Add conlet resources to page
224        channel.respond(new AddConletType(type())
225            .addScript(new ScriptResource()
226                .setScriptUri(event.renderSupport().conletResource(
227                    type(), "Login-functions.js"))
228                .setScriptType("module"))
229            .addScript(new ScriptResource().setScriptSource(providerDefs))
230            .addCss(event.renderSupport(), WebConsoleUtils.uriFromPath(
231                "Login-style.css"))
232            .addPageContent("headerIcons", Map.of("priority", "1000"))
233            .addRenderMode(RenderMode.Content));
234    }
235
236    /**
237     * As a model has already been created in {@link #doUpdateConletState},
238     * the "new" model may already exist in the session.
239     */
240    @Override
241    protected Optional<AccountModel> createNewState(AddConletRequest event,
242            ConsoleConnection session, String conletId) throws Exception {
243        Optional<AccountModel> model
244            = stateFromSession(session.session(), conletId);
245        if (model.isPresent()) {
246            return model;
247        }
248        return super.createNewState(event, session, conletId);
249    }
250
251    @Override
252    protected Optional<AccountModel> createStateRepresentation(Event<?> event,
253            ConsoleConnection channel, String conletId) throws IOException {
254        return Optional.of(new AccountModel(conletId));
255    }
256
257    /**
258     * The component can be configured with events that include
259     * a path (see @link {@link ConfigurationUpdate#paths()})
260     * that matches this components path (see {@link Manager#componentPath()}).
261     * 
262     * The following properties are recognized:
263     * 
264     * `users`
265     * : See {@link LoginConlet}.
266     * 
267     * @param event the event
268     */
269    @SuppressWarnings("unchecked")
270    @Handler
271    public void onConfigUpdate(ConfigurationUpdate event) {
272        users = event.structured(componentPath())
273            .map(c -> (List<Map<String, String>>) c.get("users"))
274            .orElseGet(Collections::emptyList).stream()
275            .collect(Collectors.toMap(e -> e.get("name"), Function.identity()));
276        ObjectMapper mapper = new ObjectMapper();
277        providers = event.structured(componentPath())
278            .map(c -> (List<Map<String, String>>) c.get("oidcProviders"))
279            .orElseGet(Collections::emptyList).stream()
280            .map(e -> mapper.convertValue(e, OidcProviderData.class))
281            .collect(
282                Collectors.toMap(OidcProviderData::name, Function.identity()));
283    }
284
285    /**
286     * Handle web console page loaded.
287     *
288     * @param event the event
289     * @param channel the channel
290     * @throws IOException 
291     * @throws ParseException 
292     * @throws MalformedTemplateNameException 
293     * @throws TemplateNotFoundException 
294     */
295    @Handler(priority = 1000)
296    public void onConsolePrepared(ConsolePrepared event,
297            ConsoleConnection channel)
298            throws TemplateNotFoundException, MalformedTemplateNameException,
299            ParseException, IOException {
300        // If we are logged in, proceed
301        if (channel.session().containsKey(Subject.class)) {
302            return;
303        }
304
305        // Suspend handling and save event "in" channel.
306        event.suspendHandling();
307        channel.setAssociated(PENDING_CONSOLE_PREPARED, event);
308
309        // Create model and save in session.
310        String conletId = type() + TYPE_INSTANCE_SEPARATOR + "Singleton";
311        AccountModel accountModel = new AccountModel(conletId);
312        accountModel.setDialogOpen(true);
313        putInSession(channel.session(), conletId, accountModel);
314
315        // Render login dialog
316        var fmModel = fmSessionModel(channel.session());
317        fmModel.put("hasUser", !users.isEmpty());
318        fmModel.put("providers", providers);
319        Template tpl = freemarkerConfig().getTemplate("Login-dialog.ftl.html");
320        var bundle = resourceBundle(channel.locale());
321        channel.respond(new OpenModalDialog(type(), conletId,
322            processTemplate(event, tpl, fmModel))
323                .addOption("title", bundle.getString("title"))
324                .addOption("cancelable", false)
325                .addOption("applyLabel",
326                    providers.isEmpty() ? bundle.getString("Log in") : "")
327                .addOption("useSubmit", true));
328    }
329
330    private Future<String> processTemplate(
331            Event<?> request, Template template,
332            Object dataModel) {
333        return request.processedBy().map(EventPipeline::executorService)
334            .orElse(Components.defaultExecutorService()).submit(() -> {
335                StringWriter out = new StringWriter();
336                try {
337                    template.process(dataModel, out);
338                } catch (TemplateException | IOException e) {
339                    throw new IllegalArgumentException(e);
340                }
341                return out.toString();
342
343            });
344    }
345
346    @Override
347    protected Set<RenderMode> doRenderConlet(RenderConletRequestBase<?> event,
348            ConsoleConnection channel, String conletId,
349            AccountModel model) throws Exception {
350        Set<RenderMode> renderedAs = new HashSet<>();
351        if (event.renderAs().contains(RenderMode.Content)) {
352            Template tpl
353                = freemarkerConfig().getTemplate("Login-status.ftl.html");
354            channel.respond(new RenderConlet(type(), conletId,
355                processTemplate(event, tpl,
356                    fmModel(event, channel, conletId, model)))
357                        .setRenderAs(RenderMode.Content));
358            channel.respond(new NotifyConletView(type(), conletId,
359                "updateUser",
360                WebConsoleUtils.userFromSession(channel.session())
361                    .map(ConsoleUser::getDisplayName).orElse(null)));
362            renderedAs.add(RenderMode.Content);
363        }
364        return renderedAs;
365    }
366
367    @Override
368    @SuppressWarnings({ "PMD.AvoidLiteralsInIfCondition",
369        "PMD.AvoidLiteralsInIfCondition", "PMD.AvoidLiteralsInIfCondition" })
370    protected void doUpdateConletState(NotifyConletModel event,
371            ConsoleConnection connection, AccountModel model) throws Exception {
372        if ("loginData".equals(event.method())) {
373            attemptLocalLogin(event, connection, model);
374            return;
375        }
376        if ("useProvider".equals(event.method())) {
377            var locales = Optional.ofNullable(
378                (Selection) connection.session().get(Selection.class))
379                .map(Selection::get).orElse(new Locale[0]);
380            fire(new StartOidcLogin(providers.get(event.params().asString(0)),
381                locales).setAssociated(this,
382                    new LoginContext(connection, model)));
383            return;
384        }
385        if ("logout".equals(event.method())) {
386            Optional.ofNullable((Subject) connection.session()
387                .get(Subject.class)).map(UserLoggedOut::new).map(this::fire);
388            connection.responsePipeline()
389                .fire(new Close(), connection.upstreamChannel()).get();
390            connection.close();
391            connection.respond(new DiscardSession(connection.session(),
392                connection.webletChannel()));
393            // Alternative to sending Close (see above):
394            // channel.respond(new SimpleConsoleCommand("reload"));
395        }
396    }
397
398    private void attemptLocalLogin(NotifyConletModel event,
399            ConsoleConnection connection, AccountModel model) {
400        var bundle = resourceBundle(connection.locale());
401        String userName = event.params().asString(0);
402        if (userName == null || userName.isEmpty()) {
403            connection.respond(new NotifyConletView(type(),
404                model.getConletId(), "setMessages",
405                null, bundle.getString("emptyUserName")));
406            return;
407        }
408        var userData = users.get(userName);
409        String password = event.params().asString(1);
410        if (userData == null
411            || !BCrypt.verifyer().verify(password.getBytes(),
412                userData.get("password").getBytes()).verified) {
413            connection.respond(new NotifyConletView(type(),
414                model.getConletId(), "setMessages",
415                null, bundle.getString("invalidCredentials")));
416            return;
417        }
418        Subject subject = new Subject();
419        var user = new ConsoleUser(userName,
420            Optional.ofNullable(userData.get("fullName")).orElse(userName));
421        if (userData.containsKey("email")) {
422            try {
423                user.setEmail(
424                    new InternetAddress(userData.get("email")));
425            } catch (AddressException e) {
426                logger.log(Level.WARNING, e,
427                    () -> "Failed to parse email address \""
428                        + userData.get("email") + "\": " + e.getMessage());
429            }
430        }
431        subject.getPrincipals().add(user);
432        fire(new UserAuthenticated(event.setAssociated(this,
433            new LoginContext(connection, model)), subject).by("Local Login"));
434    }
435
436    /**
437     * Invoked when the OIDC client has assembled the required information
438     * for contacting the provider.
439     *
440     * @param event the event
441     * @param channel the channel
442     */
443    @Handler
444    public void onOpenLoginWindow(OpenLoginWindow event, Channel channel) {
445        event.forLogin().associated(this, LoginContext.class)
446            .ifPresent(ctx -> {
447                ctx.connection.respond(new NotifyConletView(type(),
448                    ctx.model.getConletId(), "openLoginWindow",
449                    event.uri().toString()));
450            });
451    }
452
453    /**
454     * On oidc error.
455     *
456     * @param event the event
457     * @param channel the channel
458     */
459    @Handler
460    public void onOidcError(OidcError event, Channel channel) {
461        var ctx
462            = event.event().associated(this, LoginContext.class).orElse(null);
463        if (ctx == null) {
464            return;
465        }
466        var connection = ctx.connection;
467        var msg = event.kind() == OidcError.Kind.ACCESS_DENIED
468            ? resourceBundle(connection.locale()).getString("accessDenied")
469            : resourceBundle(connection.locale()).getString("oidcError");
470        connection.respond(new DisplayNotification("<span>"
471            + msg + "</span>").addOption("type", "Warning")
472                .addOption("autoClose", 5000));
473    }
474
475    /**
476     * Invoked when a user has been authenticated.
477     *
478     * @param event the event
479     * @param channel the channel
480     */
481    @Handler
482    public void onUserAuthenticated(UserAuthenticated event, Channel channel) {
483        var ctx = event.forLogin().associated(this, LoginContext.class)
484            .orElse(null);
485        if (ctx == null) {
486            return;
487        }
488        var connection = ctx.connection;
489        var model = ctx.model;
490        model.setDialogOpen(false);
491        connection.session().put(Subject.class, event.subject());
492        connection.respond(new CloseModalDialog(type(), model.getConletId()));
493        connection
494            .associated(PENDING_CONSOLE_PREPARED, ConsolePrepared.class)
495            .ifPresentOrElse(ConsolePrepared::resumeHandling,
496                () -> connection
497                    .respond(new SimpleConsoleCommand("reload")));
498    }
499
500    /**
501     * Do set locale.
502     *
503     * @param event the event
504     * @param channel the channel
505     * @param conletId the conlet id
506     * @return true, if successful
507     * @throws Exception the exception
508     */
509    @Override
510    protected boolean doSetLocale(SetLocale event, ConsoleConnection channel,
511            String conletId) throws Exception {
512        return stateFromSession(channel.session(),
513            type() + TYPE_INSTANCE_SEPARATOR + "Singleton")
514                .map(model -> !model.isDialogOpen()).orElse(true);
515    }
516
517    /**
518     * The context to preserve during the authentication process.
519     */
520    private class LoginContext {
521        public final ConsoleConnection connection;
522        public final AccountModel model;
523
524        /**
525         * Instantiates a new oidc context.
526         *
527         * @param connection the connection
528         * @param model the model
529         */
530        public LoginContext(ConsoleConnection connection, AccountModel model) {
531            this.connection = connection;
532            this.model = model;
533        }
534    }
535
536    /**
537     * Model with account info.
538     */
539    public static class AccountModel extends ConletBaseModel {
540
541        private boolean dialogOpen;
542
543        /**
544         * Creates a new model with the given type and id.
545         *
546         * @param conletId the conlet id
547         */
548        @ConstructorProperties({ "model" })
549        public AccountModel(String conletId) {
550            super(conletId);
551        }
552
553        /**
554         * Checks if is dialog open.
555         *
556         * @return true, if is dialog open
557         */
558        public boolean isDialogOpen() {
559            return dialogOpen;
560        }
561
562        /**
563         * Sets the dialog open.
564         *
565         * @param dialogOpen the new dialog open
566         */
567        public void setDialogOpen(boolean dialogOpen) {
568            this.dialogOpen = dialogOpen;
569        }
570    }
571
572}