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}