001/*
002 * This file is part of the Keycloak Moodle authenticator
003 * Copyright (C) 2024 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 Lesser General Public License as published
007 * by 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 Lesser General Public 
013 * License for more details.
014 *
015 * You should have received a copy of the GNU Lesser General Public License along 
016 * with this program; if not, see <http://www.gnu.org/licenses/>.
017 */
018
019package org.jdrupes.keycloak.moodleauth;
020
021import jakarta.ws.rs.core.MultivaluedMap;
022import java.io.IOException;
023import java.util.Optional;
024import org.jdrupes.keycloak.moodleauth.moodle.MoodleServiceProvider;
025import org.jdrupes.keycloak.moodleauth.moodle.model.MoodleUser;
026import org.jdrupes.keycloak.moodleauth.moodle.service.MoodleAuthFailedException;
027import org.jdrupes.keycloak.moodleauth.moodle.service.MoodleClient;
028import org.jdrupes.keycloak.moodleauth.moodle.service.Password;
029import org.keycloak.authentication.AuthenticationFlowContext;
030import org.keycloak.authentication.AuthenticationFlowError;
031import org.keycloak.authentication.Authenticator;
032import org.keycloak.forms.login.LoginFormsProvider;
033import org.keycloak.models.AuthenticatorConfigModel;
034import org.keycloak.models.KeycloakSession;
035import org.keycloak.models.RealmModel;
036import org.keycloak.models.UserModel;
037import org.keycloak.services.ServicesLogger;
038
039/**
040 * The Class MoodleAuthenticator.
041 */
042public class MoodleAuthenticator implements Authenticator {
043
044    private static ServicesLogger log = ServicesLogger.LOGGER;
045
046    /**
047     * User does not have to been identified, because this is
048     * a combined login/auto registration form. So return false;
049     *
050     * @return false
051     */
052    @Override
053    public boolean requiresUser() {
054        return false;
055    }
056
057    /**
058     * Is this authenticator configured for this user.
059     *
060     * @param session the session
061     * @param realm the realm
062     * @param user the user
063     * @return true, if successful
064     */
065    @Override
066    public boolean configuredFor(KeycloakSession session, RealmModel realm,
067            UserModel user) {
068        return true;
069    }
070
071    /**
072     * Never called because
073     * {@link MoodleAuthenticatorFactory#isUserSetupAllowed()} returns
074     * false.
075     *
076     * @param session the session
077     * @param realm the realm
078     * @param user the user
079     */
080    @Override
081    public void setRequiredActions(KeycloakSession session, RealmModel realm,
082            UserModel user) {
083    }
084
085    /**
086     * Not documented by keycloak. Probably supposed to release resources.
087     * Does nothing.
088     */
089    @Override
090    public void close() {
091    }
092
093    /**
094     * Start the authentication by creating the form. 
095     *
096     * @param context the context
097     */
098    @Override
099    public void authenticate(AuthenticationFlowContext context) {
100        var challenge = formsProvider(context).createForm("moodle-login.ftl");
101        context.challenge(challenge);
102    }
103
104    /**
105     * Called when the form has been submitted. Checks if the
106     * user/password combination is valid by trying to access the Moodle
107     * instance. If successful, creates the user if it doesn't exist yet.
108     *
109     * @param context the context
110     */
111    @Override
112    public void action(AuthenticationFlowContext context) {
113        MultivaluedMap<String, String> formData
114            = context.getHttpRequest().getDecodedFormParameters();
115        if (formData.containsKey("cancel")) {
116            context.cancelLogin();
117            return;
118        }
119
120        // Get Moodle Service
121        String moodleUrl = Optional.ofNullable(context.getAuthenticatorConfig())
122            .map(AuthenticatorConfigModel::getConfig)
123            .map(m -> m.get(MoodleAuthenticatorFactory.MOODLE_URL)).orElse("");
124        if (moodleUrl.isEmpty()) {
125            var challenge = formsProvider(context).setError("missingMoodleUrl")
126                .createForm("moodle-login.ftl");
127            context.failureChallenge(
128                AuthenticationFlowError.IDENTITY_PROVIDER_ERROR, challenge);
129            log.error("Moodle URL not configured.");
130            return;
131        }
132        var moodleServiceProvider = new MoodleServiceProvider();
133        var username = formData.getFirst("username");
134        try (var moodleClient = moodleServiceProvider.connect(moodleUrl,
135            username,
136            new Password(formData.getFirst("password").toCharArray()))) {
137
138            // Create non-existant user and update.
139            var userProvider = context.getSession().users();
140            var user = Optional.ofNullable(userProvider
141                .getUserByUsername(context.getRealm(), username))
142                .orElseGet(() -> {
143                    var data
144                        = userProvider.addUser(context.getRealm(), username);
145                    data.setEnabled(true);
146                    return data;
147                });
148            updateUser(context, user, moodleClient);
149            context.setUser(user);
150            context.success();
151        } catch (IOException e) {
152            var challenge
153                = formsProvider(context).setError("temoraryMoodleFailure")
154                    .createForm("moodle-login.ftl");
155            context.failureChallenge(
156                AuthenticationFlowError.IDENTITY_PROVIDER_ERROR, challenge);
157            return;
158        } catch (MoodleAuthFailedException e) {
159            var challenge = formsProvider(context)
160                .setError("invalidUserMessage").createForm("moodle-login.ftl");
161            context.failureChallenge(
162                AuthenticationFlowError.INVALID_CREDENTIALS, challenge);
163            return;
164        }
165    }
166
167    private UserModel updateUser(AuthenticationFlowContext context,
168            UserModel kcUser, MoodleClient moodleClient) {
169        MoodleUser moodleUser = moodleClient.moodleUser();
170        kcUser.setEmail(moodleUser.getEmail());
171        kcUser.setEmailVerified(true);
172        var siteInfo = moodleClient.siteInfo();
173        if (moodleUser.getFirstname() != null
174            && !moodleUser.getFirstname().isBlank()) {
175            kcUser.setFirstName(moodleUser.getFirstname());
176        } else {
177            kcUser.setFirstName(siteInfo.getFirstname());
178        }
179        if (moodleUser.getLastname() != null
180            && !moodleUser.getLastname().isBlank()) {
181            kcUser.setLastName(moodleUser.getLastname());
182        } else {
183            kcUser.setLastName(siteInfo.getLastname());
184        }
185        return kcUser;
186    }
187
188    private LoginFormsProvider
189            formsProvider(AuthenticationFlowContext context) {
190        LoginFormsProvider form = context.form();
191        var moodleUrl = context.getAuthenticatorConfig().getConfig()
192            .get(MoodleAuthenticatorFactory.MOODLE_URL);
193        form.setAttribute(MoodleAuthenticatorFactory.MOODLE_URL, moodleUrl);
194        return form;
195    }
196
197}