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.HashSet;
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.DataflowAnomalyAnalysis", "PMD.CouplingBetweenObjects",
153    "PMD.ExcessiveImports" })
154public class LoginConlet extends FreeMarkerConlet<LoginConlet.AccountModel> {
155
156    private static final String PENDING_CONSOLE_PREPARED
157        = "pendingConsolePrepared";
158    private Map<String, OidcProviderData> providers
159        = new ConcurrentHashMap<>();
160    private Map<String, Map<String, String>> users
161        = new ConcurrentHashMap<>();
162
163    /**
164     * Creates a new component with its channel set to the given channel.
165     * 
166     * @param componentChannel the channel that the component's handlers listen
167     *            on by default and that {@link Manager#fire(Event, Channel...)}
168     *            sends the event to
169     */
170    public LoginConlet(Channel componentChannel) {
171        super(componentChannel);
172    }
173
174    @Override
175    protected String generateInstanceId(AddConletRequest event,
176            ConsoleConnection session) {
177        return "Singleton";
178    }
179
180    /**
181     * Register conlet.
182     *
183     * @param event the event
184     * @param channel the channel
185     * @throws TemplateNotFoundException the template not found exception
186     * @throws MalformedTemplateNameException the malformed template name exception
187     * @throws ParseException the parse exception
188     * @throws IOException Signals that an I/O exception has occurred.
189     */
190    @Handler
191    public void onConsoleReady(ConsoleReady event, ConsoleConnection channel)
192            throws TemplateNotFoundException, MalformedTemplateNameException,
193            ParseException, IOException {
194        // Define providers
195        StringBuffer script = new StringBuffer(1000);
196        script.append("window.orgJGrapesOidcLogin"
197            + " = window.orgJGrapesOidcLogin || {};"
198            + "window.orgJGrapesOidcLogin.providers = [];");
199        boolean first = true;
200        for (var e : providers.entrySet()) {
201            if (first) {
202                script.append("let ");
203                first = false;
204            }
205            script.append("provider = new Map();"
206                + "window.orgJGrapesOidcLogin.providers.push(provider);"
207                + "provider.set('name', '").append(e.getKey())
208                .append("');provider.set('displayName', '")
209                .append(e.getValue().displayName()).append("');");
210            if (e.getValue().popup() != null) {
211                for (var d : e.getValue().popup().entrySet()) {
212                    String key = "popup" + d.getKey().substring(0, 1)
213                        .toUpperCase() + d.getKey().substring(1);
214                    script.append("provider.set('").append(key).append("', '")
215                        .append(d.getValue()).append("');");
216                }
217            }
218        }
219        var providerDefs = script.toString();
220        // Add conlet resources to page
221        channel.respond(new AddConletType(type())
222            .addScript(new ScriptResource()
223                .setScriptUri(event.renderSupport().conletResource(
224                    type(), "Login-functions.js"))
225                .setScriptType("module"))
226            .addScript(new ScriptResource().setScriptSource(providerDefs))
227            .addCss(event.renderSupport(), WebConsoleUtils.uriFromPath(
228                "Login-style.css"))
229            .addPageContent("headerIcons", Map.of("priority", "1000"))
230            .addRenderMode(RenderMode.Content));
231    }
232
233    /**
234     * As a model has already been created in {@link #doUpdateConletState},
235     * the "new" model may already exist in the session.
236     */
237    @Override
238    protected Optional<AccountModel> createNewState(AddConletRequest event,
239            ConsoleConnection session, String conletId) throws Exception {
240        Optional<AccountModel> model
241            = stateFromSession(session.session(), conletId);
242        if (model.isPresent()) {
243            return model;
244        }
245        return super.createNewState(event, session, conletId);
246    }
247
248    @Override
249    protected Optional<AccountModel> createStateRepresentation(Event<?> event,
250            ConsoleConnection channel, String conletId) throws IOException {
251        return Optional.of(new AccountModel(conletId));
252    }
253
254    /**
255     * The component can be configured with events that include
256     * a path (see @link {@link ConfigurationUpdate#paths()})
257     * that matches this components path (see {@link Manager#componentPath()}).
258     * 
259     * The following properties are recognized:
260     * 
261     * `users`
262     * : See {@link LoginConlet}.
263     * 
264     * @param event the event
265     */
266    @SuppressWarnings("unchecked")
267    @Handler
268    public void onConfigUpdate(ConfigurationUpdate event) {
269        users = event.structured(componentPath())
270            .map(c -> (List<Map<String, String>>) c.get("users"))
271            .orElseGet(Collections::emptyList).stream()
272            .collect(Collectors.toMap(e -> e.get("name"), Function.identity()));
273        ObjectMapper mapper = new ObjectMapper();
274        providers = event.structured(componentPath())
275            .map(c -> (List<Map<String, String>>) c.get("oidcProviders"))
276            .orElseGet(Collections::emptyList).stream()
277            .map(e -> mapper.convertValue(e, OidcProviderData.class))
278            .collect(
279                Collectors.toMap(OidcProviderData::name, Function.identity()));
280    }
281
282    /**
283     * Handle web console page loaded.
284     *
285     * @param event the event
286     * @param channel the channel
287     * @throws IOException 
288     * @throws ParseException 
289     * @throws MalformedTemplateNameException 
290     * @throws TemplateNotFoundException 
291     */
292    @Handler(priority = 1000)
293    public void onConsolePrepared(ConsolePrepared event,
294            ConsoleConnection channel)
295            throws TemplateNotFoundException, MalformedTemplateNameException,
296            ParseException, IOException {
297        // If we are logged in, proceed
298        if (channel.session().containsKey(Subject.class)) {
299            return;
300        }
301
302        // Suspend handling and save event "in" channel.
303        event.suspendHandling();
304        channel.setAssociated(PENDING_CONSOLE_PREPARED, event);
305
306        // Create model and save in session.
307        String conletId = type() + TYPE_INSTANCE_SEPARATOR + "Singleton";
308        AccountModel accountModel = new AccountModel(conletId);
309        accountModel.setDialogOpen(true);
310        putInSession(channel.session(), conletId, accountModel);
311
312        // Render login dialog
313        var fmModel = fmSessionModel(channel.session());
314        fmModel.put("hasUser", !users.isEmpty());
315        fmModel.put("providers", providers);
316        Template tpl = freemarkerConfig().getTemplate("Login-dialog.ftl.html");
317        var bundle = resourceBundle(channel.locale());
318        channel.respond(new OpenModalDialog(type(), conletId,
319            processTemplate(event, tpl, fmModel))
320                .addOption("title", bundle.getString("title"))
321                .addOption("cancelable", false)
322                .addOption("applyLabel",
323                    providers.isEmpty() ? bundle.getString("Log in") : "")
324                .addOption("useSubmit", true));
325    }
326
327    @Override
328    protected Set<RenderMode> doRenderConlet(RenderConletRequestBase<?> event,
329            ConsoleConnection channel, String conletId,
330            AccountModel model) throws Exception {
331        Set<RenderMode> renderedAs = new HashSet<>();
332        if (event.renderAs().contains(RenderMode.Content)) {
333            Template tpl
334                = freemarkerConfig().getTemplate("Login-status.ftl.html");
335            channel.respond(new RenderConlet(type(), conletId,
336                processTemplate(event, tpl,
337                    fmModel(event, channel, conletId, model)))
338                        .setRenderAs(RenderMode.Content));
339            channel.respond(new NotifyConletView(type(), conletId,
340                "updateUser",
341                WebConsoleUtils.userFromSession(channel.session())
342                    .map(ConsoleUser::getDisplayName).orElse(null)));
343            renderedAs.add(RenderMode.Content);
344        }
345        return renderedAs;
346    }
347
348    @Override
349    @SuppressWarnings({ "PMD.AvoidLiteralsInIfCondition",
350        "PMD.AvoidLiteralsInIfCondition", "PMD.AvoidLiteralsInIfCondition" })
351    protected void doUpdateConletState(NotifyConletModel event,
352            ConsoleConnection connection, AccountModel model) throws Exception {
353        if ("loginData".equals(event.method())) {
354            attemptLocalLogin(event, connection, model);
355            return;
356        }
357        if ("useProvider".equals(event.method())) {
358            var locales = Optional.ofNullable(
359                (Selection) connection.session().get(Selection.class))
360                .map(Selection::get).orElse(new Locale[0]);
361            fire(new StartOidcLogin(providers.get(event.params().asString(0)),
362                locales).setAssociated(this,
363                    new LoginContext(connection, model)));
364            return;
365        }
366        if ("logout".equals(event.method())) {
367            Optional.ofNullable((Subject) connection.session()
368                .get(Subject.class)).map(UserLoggedOut::new).map(this::fire);
369            connection.responsePipeline()
370                .fire(new Close(), connection.upstreamChannel()).get();
371            connection.close();
372            connection.respond(new DiscardSession(connection.session(),
373                connection.webletChannel()));
374            // Alternative to sending Close (see above):
375            // channel.respond(new SimpleConsoleCommand("reload"));
376        }
377    }
378
379    private void attemptLocalLogin(NotifyConletModel event,
380            ConsoleConnection connection, AccountModel model) {
381        var bundle = resourceBundle(connection.locale());
382        String userName = event.params().asString(0);
383        if (userName == null || userName.isEmpty()) {
384            connection.respond(new NotifyConletView(type(),
385                model.getConletId(), "setMessages",
386                null, bundle.getString("emptyUserName")));
387            return;
388        }
389        var userData = users.get(userName);
390        String password = event.params().asString(1);
391        if (userData == null
392            || !BCrypt.verifyer().verify(password.getBytes(),
393                userData.get("password").getBytes()).verified) {
394            connection.respond(new NotifyConletView(type(),
395                model.getConletId(), "setMessages",
396                null, bundle.getString("invalidCredentials")));
397            return;
398        }
399        Subject subject = new Subject();
400        var user = new ConsoleUser(userName,
401            Optional.ofNullable(userData.get("fullName")).orElse(userName));
402        if (userData.containsKey("email")) {
403            try {
404                user.setEmail(
405                    new InternetAddress(userData.get("email")));
406            } catch (AddressException e) {
407                logger.log(Level.WARNING, e,
408                    () -> "Failed to parse email address \""
409                        + userData.get("email") + "\": " + e.getMessage());
410            }
411        }
412        subject.getPrincipals().add(user);
413        fire(new UserAuthenticated(event.setAssociated(this,
414            new LoginContext(connection, model)), subject).by("Local Login"));
415    }
416
417    /**
418     * Invoked when the OIDC client has assembled the required information
419     * for contacting the provider.
420     *
421     * @param event the event
422     * @param channel the channel
423     */
424    @Handler
425    public void onOpenLoginWindow(OpenLoginWindow event, Channel channel) {
426        event.forLogin().associated(this, LoginContext.class)
427            .ifPresent(ctx -> {
428                ctx.connection.respond(new NotifyConletView(type(),
429                    ctx.model.getConletId(), "openLoginWindow",
430                    event.uri().toString()));
431            });
432    }
433
434    /**
435     * On oidc error.
436     *
437     * @param event the event
438     * @param channel the channel
439     */
440    @Handler
441    public void onOidcError(OidcError event, Channel channel) {
442        var ctx
443            = event.event().associated(this, LoginContext.class).orElse(null);
444        if (ctx == null) {
445            return;
446        }
447        var connection = ctx.connection;
448        String msg;
449        switch (event.kind()) {
450        case ACCESS_DENIED:
451            msg = resourceBundle(connection.locale()).getString("accessDenied");
452            break;
453        case INTERNAL_SERVER_ERROR:
454            msg = resourceBundle(connection.locale()).getString("serverError");
455            break;
456        default:
457            msg = resourceBundle(connection.locale()).getString("oidcError");
458            break;
459        }
460        connection.respond(new DisplayNotification("<span>"
461            + msg + "</span>").addOption("type", "Warning")
462                .addOption("autoClose", 5000));
463    }
464
465    /**
466     * Invoked when a user has been authenticated.
467     *
468     * @param event the event
469     * @param channel the channel
470     */
471    @Handler
472    public void onUserAuthenticated(UserAuthenticated event, Channel channel) {
473        var ctx = event.forLogin().associated(this, LoginContext.class)
474            .orElse(null);
475        if (ctx == null) {
476            return;
477        }
478        var connection = ctx.connection;
479        var model = ctx.model;
480        model.setDialogOpen(false);
481        connection.session().put(Subject.class, event.subject());
482        connection.respond(new CloseModalDialog(type(), model.getConletId()));
483        connection
484            .associated(PENDING_CONSOLE_PREPARED, ConsolePrepared.class)
485            .ifPresentOrElse(ConsolePrepared::resumeHandling,
486                () -> connection
487                    .respond(new SimpleConsoleCommand("reload")));
488    }
489
490    /**
491     * Do set locale.
492     *
493     * @param event the event
494     * @param channel the channel
495     * @param conletId the conlet id
496     * @return true, if successful
497     * @throws Exception the exception
498     */
499    @Override
500    protected boolean doSetLocale(SetLocale event, ConsoleConnection channel,
501            String conletId) throws Exception {
502        return stateFromSession(channel.session(),
503            type() + TYPE_INSTANCE_SEPARATOR + "Singleton")
504                .map(model -> !model.isDialogOpen()).orElse(true);
505    }
506
507    /**
508     * The context to preserve during the authentication process.
509     */
510    private class LoginContext {
511        public final ConsoleConnection connection;
512        public final AccountModel model;
513
514        /**
515         * Instantiates a new oidc context.
516         *
517         * @param connection the connection
518         * @param model the model
519         */
520        public LoginContext(ConsoleConnection connection, AccountModel model) {
521            this.connection = connection;
522            this.model = model;
523        }
524    }
525
526    /**
527     * Model with account info.
528     */
529    public static class AccountModel extends ConletBaseModel {
530
531        private boolean dialogOpen;
532
533        /**
534         * Creates a new model with the given type and id.
535         *
536         * @param conletId the conlet id
537         */
538        @ConstructorProperties({ "model" })
539        public AccountModel(String conletId) {
540            super(conletId);
541        }
542
543        /**
544         * Checks if is dialog open.
545         *
546         * @return true, if is dialog open
547         */
548        public boolean isDialogOpen() {
549            return dialogOpen;
550        }
551
552        /**
553         * Sets the dialog open.
554         *
555         * @param dialogOpen the new dialog open
556         */
557        public void setDialogOpen(boolean dialogOpen) {
558            this.dialogOpen = dialogOpen;
559        }
560    }
561
562}