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