001/*
002 *  Copyright 2022 Anyware Services
003 *
004 *  Licensed under the Apache License, Version 2.0 (the "License");
005 *  you may not use this file except in compliance with the License.
006 *  You may obtain a copy of the License at
007 *
008 *      http://www.apache.org/licenses/LICENSE-2.0
009 *
010 *  Unless required by applicable law or agreed to in writing, software
011 *  distributed under the License is distributed on an "AS IS" BASIS,
012 *  WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
013 *  See the License for the specific language governing permissions and
014 *  limitations under the License.
015 */
016package org.ametys.plugins.extrausermgt.authentication.oidc;
017
018import java.io.IOException;
019import java.net.URI;
020import java.net.URISyntaxException;
021import java.net.URL;
022import java.util.Date;
023import java.util.List;
024import java.util.Map;
025import java.util.stream.Collectors;
026import java.util.stream.Stream;
027
028import org.apache.avalon.framework.context.Context;
029import org.apache.avalon.framework.context.ContextException;
030import org.apache.avalon.framework.context.Contextualizable;
031import org.apache.avalon.framework.service.ServiceException;
032import org.apache.avalon.framework.service.ServiceManager;
033import org.apache.avalon.framework.service.Serviceable;
034import org.apache.cocoon.ProcessingException;
035import org.apache.cocoon.components.ContextHelper;
036import org.apache.cocoon.environment.Redirector;
037import org.apache.cocoon.environment.Request;
038import org.apache.cocoon.environment.Session;
039import org.apache.commons.lang3.StringUtils;
040
041import org.ametys.core.authentication.AbstractCredentialProvider;
042import org.ametys.core.authentication.AuthenticateAction;
043import org.ametys.core.authentication.BlockingCredentialProvider;
044import org.ametys.core.user.User;
045import org.ametys.core.user.UserIdentity;
046import org.ametys.core.user.directory.NotUniqueUserException;
047import org.ametys.core.user.directory.UserDirectory;
048import org.ametys.core.user.population.UserPopulation;
049import org.ametys.plugins.extrausermgt.authentication.oidc.endofauthenticationprocess.EndOfAuthenticationProcess;
050import org.ametys.runtime.authentication.AccessDeniedException;
051
052import com.nimbusds.jose.JOSEException;
053import com.nimbusds.jose.JWSAlgorithm;
054import com.nimbusds.jose.proc.BadJOSEException;
055import com.nimbusds.jwt.JWT;
056import com.nimbusds.oauth2.sdk.AuthorizationCode;
057import com.nimbusds.oauth2.sdk.AuthorizationCodeGrant;
058import com.nimbusds.oauth2.sdk.AuthorizationGrant;
059import com.nimbusds.oauth2.sdk.ParseException;
060import com.nimbusds.oauth2.sdk.RefreshTokenGrant;
061import com.nimbusds.oauth2.sdk.ResponseType;
062import com.nimbusds.oauth2.sdk.Scope;
063import com.nimbusds.oauth2.sdk.SerializeException;
064import com.nimbusds.oauth2.sdk.TokenErrorResponse;
065import com.nimbusds.oauth2.sdk.TokenRequest;
066import com.nimbusds.oauth2.sdk.TokenResponse;
067import com.nimbusds.oauth2.sdk.auth.ClientAuthentication;
068import com.nimbusds.oauth2.sdk.auth.ClientSecretBasic;
069import com.nimbusds.oauth2.sdk.auth.Secret;
070import com.nimbusds.oauth2.sdk.http.HTTPResponse;
071import com.nimbusds.oauth2.sdk.id.ClientID;
072import com.nimbusds.oauth2.sdk.id.Issuer;
073import com.nimbusds.oauth2.sdk.id.State;
074import com.nimbusds.oauth2.sdk.token.AccessToken;
075import com.nimbusds.oauth2.sdk.token.RefreshToken;
076import com.nimbusds.openid.connect.sdk.AuthenticationRequest;
077import com.nimbusds.openid.connect.sdk.OIDCTokenResponse;
078import com.nimbusds.openid.connect.sdk.OIDCTokenResponseParser;
079import com.nimbusds.openid.connect.sdk.UserInfoRequest;
080import com.nimbusds.openid.connect.sdk.UserInfoResponse;
081import com.nimbusds.openid.connect.sdk.claims.IDTokenClaimsSet;
082import com.nimbusds.openid.connect.sdk.claims.UserInfo;
083import com.nimbusds.openid.connect.sdk.token.OIDCTokens;
084import com.nimbusds.openid.connect.sdk.validators.IDTokenValidator;
085
086/**
087 * Sign in (through Google, facebook...) using the OpenId Connect (OIDC) protocol.
088 */
089public abstract class AbstractOIDCCredentialProvider extends AbstractCredentialProvider implements BlockingCredentialProvider, Contextualizable, Serviceable
090{
091    /** Session attribute for OIDC */
092    public static final String REDIRECT_URI_SESSION_ATTRIBUTE = "oidc_actualRedirectUri";
093    /** Session attribute for OIDC*/
094    public static final String TOKEN_SESSION_ATTRIBUTE = "oidc_token";
095    /** Session date attribute for OIDC*/
096    public static final String EXPDATE_SESSION_ATTRIBUTE = "oidc_expirationDate";
097    /** Session attribute for OIDC*/
098    public static final String REFRESH_TOKEN_SESSION_ATTRIBUTE = "oidc_refreshToken";
099    /** Session attribute for OIDC*/
100    public static final String STATE_SESSION_ATTRIBUTE = "oidc_state";
101
102    /** Scope  for the authentication request */
103    protected Scope _scope;
104    
105    /** URI for the authentication request */
106    protected URI _authUri;
107    
108    /** URI for the token request */
109    protected URI _tokenEndpointUri;
110    
111    /** URI for the user info request */
112    protected URI _userInfoEndpoint;
113    
114    /** jwk URL for the validation of the token */
115    protected URL _jwkSetURL;
116    
117    /** Issuer  for the validation of the token */
118    protected Issuer _iss; 
119    
120    /** Ametys context */
121    protected Context _context;
122    /** Client ID */
123    protected ClientID _clientID;
124    /** Client secret */
125    protected Secret _clientSecret;
126    
127    private EndOfAuthenticationProcess _endOfAuthenticationProcess;
128    
129    public void contextualize(Context context) throws ContextException
130    {
131        _context = context;
132    }
133    
134    public void service(ServiceManager manager) throws ServiceException
135    {
136        _endOfAuthenticationProcess = (EndOfAuthenticationProcess) manager.lookup(EndOfAuthenticationProcess.ROLE);
137    }
138    
139    @Override
140    public void init(String id, String cpModelId, Map<String, Object> paramValues, String label) throws Exception
141    {
142        super.init(id, cpModelId, paramValues, label);
143        _clientID = new ClientID(paramValues.get("authentication.oidc.idclient").toString());
144        _clientSecret = new Secret(paramValues.get("authentication.oidc.clientsecret").toString());
145
146        initUrisScope();
147    }
148    
149    /**
150     * get the client authentication info for the token end point
151     * @return the client authentication
152     */
153    protected ClientAuthentication getClientAuthentication()
154    {
155        return new ClientSecretBasic(_clientID, _clientSecret);
156    }
157    
158    public boolean blockingGrantAnonymousRequest()
159    {
160        return false;
161    }
162    
163    public boolean blockingIsStillConnected(UserIdentity userIdentity, Redirector redirector) throws Exception
164    {
165        Request request = ContextHelper.getRequest(_context);
166        Session session = request.getSession(true);
167   
168        Date expDat = (Date) session.getAttribute(EXPDATE_SESSION_ATTRIBUTE);
169        if (new Date().before(expDat))
170        {
171            return true;
172        }
173        
174        RefreshToken refreshToken = (RefreshToken) session.getAttribute(REFRESH_TOKEN_SESSION_ATTRIBUTE);
175        AuthorizationGrant refreshTokenGrant = new RefreshTokenGrant(refreshToken);
176        
177        // The credentials to authenticate the client at the token endpoint
178        ClientAuthentication clientAuth = getClientAuthentication();
179        
180        // Make the token request
181        OIDCTokens tokens = requestToken(clientAuth, refreshTokenGrant);
182
183        // idToken to validate the token
184        JWT idToken = tokens.getIDToken();
185        // accessToken to be able to access the user info
186        AccessToken accessToken = tokens.getAccessToken();
187        IDTokenClaimsSet claims = validateIdToken(idToken);
188        session.setAttribute(EXPDATE_SESSION_ATTRIBUTE, claims.getExpirationTime());
189        session.setAttribute(TOKEN_SESSION_ATTRIBUTE, accessToken);
190        
191        return true;
192    }
193    
194    public UserIdentity blockingGetUserIdentity(Redirector redirector) throws Exception
195    {      
196        Request request = ContextHelper.getRequest(_context);
197        Session session = request.getSession(true);
198        
199        URI redirectUri = _buildRedirectUri();
200
201        getLogger().debug("OIDCCredentialProvider callback URI: {}", redirectUri);
202   
203        String code = request.getParameter("code");
204        // if the code is null, then this is the first time the user sign-in
205        // if no state are stored in session, then the code belongs to a previous session. Restart
206        if (code == null || session.getAttribute(STATE_SESSION_ATTRIBUTE) == null)
207        {
208            signIn(redirector, redirectUri, session);
209            return null;
210        }
211
212        // we got an authorization code   
213        // but first, check the state to prevent CSRF attacks
214        checkState();
215        AuthorizationCode authCode = new AuthorizationCode(code);
216        // get the tokens (id token and access token)
217        OIDCTokens tokens = requestToken(authCode, redirectUri);
218
219        // idToken to validate the token
220        JWT idToken = tokens.getIDToken();
221        // accessToken to be able to access the user info
222        AccessToken accessToken = tokens.getAccessToken();
223        RefreshToken refreshToken = tokens.getRefreshToken();
224        
225        session.setAttribute(REFRESH_TOKEN_SESSION_ATTRIBUTE, refreshToken);
226        
227        // validate id token
228        IDTokenClaimsSet claims = validateIdToken(idToken);
229
230        // set expirationTime
231        claims.getExpirationTime();
232        session.setAttribute(EXPDATE_SESSION_ATTRIBUTE, claims.getExpirationTime());
233        
234        
235        UserInfo userInfo = getUserInfo(accessToken);
236        
237        // then the user is finally logged in
238        return getUserIdentity(userInfo, request, redirector);
239    }
240
241    public void blockingUserNotAllowed(Redirector redirector) throws Exception
242    {
243        // Nothing to do.
244    }
245
246    public void blockingUserAllowed(UserIdentity userIdentity, Redirector redirector) throws Exception
247    {
248        Request request = ContextHelper.getRequest(_context);
249        Session session = request.getSession(true);
250        String redirectUri = (String) session.getAttribute(AbstractOIDCCredentialProvider.REDIRECT_URI_SESSION_ATTRIBUTE);
251        redirector.redirect(true, redirectUri);
252    }
253
254    public boolean requiresNewWindow()
255    {
256        return true;
257    }
258    
259    private UserPopulation _getPopulation(Request request)
260    {
261        @SuppressWarnings("unchecked")
262        List<UserPopulation> userPopulations = (List<UserPopulation>) request.getAttribute(AuthenticateAction.REQUEST_ATTRIBUTE_AVAILABLE_USER_POPULATIONS_LIST);
263
264        // If the list has only one element
265        if (userPopulations.size() == 1)
266        {
267            return userPopulations.get(0);
268        }
269
270        // In this list a population was maybe chosen?
271        final String chosenUserPopulationId = (String) request.getAttribute(AuthenticateAction.REQUEST_ATTRIBUTE_USER_POPULATION_ID);
272        if (StringUtils.isNotBlank(chosenUserPopulationId))
273        {
274            return userPopulations.stream()
275                    .filter(userPopulation -> StringUtils.equals(userPopulation.getId(), chosenUserPopulationId))
276                    .findFirst()
277                    .get();
278        }
279
280        // Cannot work here...
281        throw new IllegalStateException("The " + this.getClass().getName() + " does not work when population is not known");
282    }
283    
284    /** 
285     * Initialize the URIs
286     * @throws AccessDeniedException If an error occurs
287     */
288    protected abstract void initUrisScope() throws AccessDeniedException;
289    
290    /** 
291     * Builds the redirect URI and the actual redirect URI
292     * @return The redirect <code>URI</code> and saves the actual redirect <code>URI</code>
293     * @throws URISyntaxException If an error occurs
294     */
295    private URI _buildRedirectUri() throws URISyntaxException 
296    {
297        Request request = ContextHelper.getRequest(_context);
298        
299        // creation of the actual redirect URI (The one we actually want to go back to)
300        StringBuilder actualRedirectUri = new StringBuilder(request.getRequestURI());
301        String queryString = request.getQueryString();
302        if (queryString != null)
303        {
304            // remove any existing code or state from the request param if any exist, they are outdated
305            queryString = Stream.of(StringUtils.split(queryString, "&"))
306                    .filter(str -> !StringUtils.startsWithAny(str, "code=", "state="))
307                    .collect(Collectors.joining("&"));
308            if (StringUtils.isNotEmpty(queryString))
309            {
310                actualRedirectUri.append("?");
311                actualRedirectUri.append(queryString);
312            }
313        }
314        
315        // saving the actualRedirectUri to enable its use in "OIDCCallbackAction"
316        Session session = request.getSession(true);
317        session.setAttribute(REDIRECT_URI_SESSION_ATTRIBUTE, actualRedirectUri.toString());
318        
319        // creation of redirect URI (the issuer (google, facebook, etc.) is going to redirect to)
320        return buildAbsoluteURI(request, "/_extra-user-management/oidc-callback");
321    }
322
323    /**
324     * Computes the callback uri
325     * @param request the current request
326     * @param path the callback path
327     * @return the callback uri
328     */
329    protected URI buildAbsoluteURI(Request request, String path)
330    {
331        StringBuilder uriBuilder = new StringBuilder()
332            .append(request.getScheme())
333            .append("://")
334            .append(request.getServerName());
335        
336        if (request.isSecure())
337        {
338            if (request.getServerPort() != 443)
339            {
340                uriBuilder.append(":");
341                uriBuilder.append(request.getServerPort());
342            }
343        }
344        else
345        {
346            if (request.getServerPort() != 80)
347            {
348                uriBuilder.append(":");
349                uriBuilder.append(request.getServerPort());
350            }
351        }
352
353        uriBuilder.append(request.getContextPath());
354        uriBuilder.append(path);
355        
356        return URI.create(uriBuilder.toString());
357    }
358    
359    /** 
360     * Sign the user in by sending an authentication request to the issuer
361     * @param redirector The redirector
362     * @param redirectUri The redirect URI
363     * @param session The current session
364     * @throws ProcessingException If an error occurs
365     * @throws IOException If an error occurs
366     */
367    protected void signIn(Redirector redirector, URI redirectUri, Session session) throws ProcessingException, IOException
368    {
369        // sign-in request: redirect the client through the actual authentication process
370        
371        // creation of the state used to secure the process
372        State state = new State();
373        session.setAttribute(STATE_SESSION_ATTRIBUTE, state);
374
375        // compose the request
376        AuthenticationRequest authenticationRequest = new AuthenticationRequest(_authUri, new ResponseType(ResponseType.Value.CODE), _scope, _clientID, redirectUri, state, null);
377        
378        String authReqURI = authenticationRequest.toURI().toString();
379        authReqURI += "&access_type=offline";
380        
381        redirector.redirect(false, authReqURI);
382    }
383    
384    /** 
385     * Checks the State parameter of the request to prevent CSRF attacks
386     * @throws AccessDeniedException If an error occurs
387     */
388    protected void checkState() throws AccessDeniedException
389    {
390        Request request = ContextHelper.getRequest(_context);
391        Session session = request.getSession(true);
392        String storedState = session.getAttribute(STATE_SESSION_ATTRIBUTE).toString();
393        String stateRequest = request.getParameter("state");
394        
395        if (!storedState.equals(stateRequest))
396        {
397            getLogger().error("OIDC state mismatch. Method checkState of AbstractOIDCCredentialProvider");
398            throw new AccessDeniedException("OIDC state mismatch");
399        }
400        
401        session.setAttribute(STATE_SESSION_ATTRIBUTE, null);
402    }
403    
404    /** 
405     * Request the tokens (ID token and Access token)
406     * @param authCode The authorization code from the authentication request
407     * @param redirectUri The redirect URI
408     * @return The <code>OIDCTokens</code> that contains the access token and the id token
409     * @throws AccessDeniedException If an error occurs
410     */
411    protected OIDCTokens requestToken(AuthorizationCode authCode, URI redirectUri) throws AccessDeniedException
412    {
413        // token request: checking if the user is known 
414        TokenRequest tokenReq = new TokenRequest(_tokenEndpointUri, getClientAuthentication(), new AuthorizationCodeGrant(authCode, redirectUri));
415        // sending request
416        HTTPResponse tokenHTTPResp = null;
417        try
418        {
419            tokenHTTPResp = tokenReq.toHTTPRequest().send();
420        }
421        catch (SerializeException | IOException e)
422        {
423            getLogger().error("OIDC token request failed ", e);
424            throw new AccessDeniedException("OIDC token request failed");
425        }
426
427        // cast the HTTPResponse to TokenResponse
428        TokenResponse tokenResponse = null;
429        try
430        {
431            tokenResponse = OIDCTokenResponseParser.parse(tokenHTTPResp);
432        }
433        catch (ParseException e)
434        {
435            getLogger().error("OIDC token request result invalid ", e);
436            throw new AccessDeniedException("OIDC token request result invalid");
437        }
438
439        if (tokenResponse instanceof TokenErrorResponse)
440        {
441            getLogger().error("OIDC token request invalid token response instance of TokenErrorResponse in method requestToken from AbstractOIDCCredentialProvider");
442            throw new AccessDeniedException("OIDC token request result invalid");
443        }
444
445        // get the tokens
446        OIDCTokenResponse  accessTokenResponse = (OIDCTokenResponse) tokenResponse;
447        
448        return accessTokenResponse.getOIDCTokens();
449    }
450    
451    /** 
452     * Request the tokens using a refresh token 
453     * @param clientAuth The client authentication
454     * @param refreshTokenGrant The refreshtokenGrant 
455     * @return The <code>OIDCTokens</code> that contains the access token and the id token
456     * @throws AccessDeniedException If an error occurs
457     * @throws URISyntaxException If an error occurs
458     */
459    protected OIDCTokens requestToken(ClientAuthentication clientAuth, AuthorizationGrant refreshTokenGrant) throws AccessDeniedException, URISyntaxException
460    {
461        // token request: checking if the user is known 
462        TokenRequest tokenReq = new TokenRequest(_tokenEndpointUri, clientAuth, refreshTokenGrant);
463        // sending request
464       
465        HTTPResponse tokenHTTPResp = null;
466        try
467        {
468            tokenHTTPResp = tokenReq.toHTTPRequest().send();
469        }
470        catch (SerializeException | IOException e)
471        {
472            getLogger().error("OIDC token request failed ", e);
473            throw new AccessDeniedException("OIDC token request failed");
474        }
475
476        // cast the HTTPResponse to TokenResponse
477        TokenResponse tokenResponse = null;
478        try
479        {
480            tokenResponse = OIDCTokenResponseParser.parse(tokenHTTPResp);
481        }
482        catch (ParseException e)
483        {
484            getLogger().error("OIDC token request result invalid ", e);
485            throw new AccessDeniedException("OIDC token request result invalid");
486        }
487
488        if (tokenResponse instanceof TokenErrorResponse)
489        {
490            getLogger().error("OIDC token request result invalid: tokenResponse instance of TokenErrorResponse in method requestToken from AbstractOIDCCredentialProvider");
491            throw new AccessDeniedException("OIDC token request result invalid");
492        }
493
494        // get the tokens
495        OIDCTokenResponse  accessTokenResponse = (OIDCTokenResponse) tokenResponse;
496        
497        return accessTokenResponse.getOIDCTokens();
498    }
499    
500    /** 
501     * Validate the id token from the token request
502     * @param idToken The id token from the token request
503     * @return The <code>IDTokenClaimsSet</code> that contains information on the connection such as the expiration time
504     * @throws AccessDeniedException If an error occurs
505     */
506    protected IDTokenClaimsSet validateIdToken(JWT idToken) throws AccessDeniedException
507    {
508        JWSAlgorithm jwsAlg = JWSAlgorithm.RS256;
509        // create validator for signed ID tokens
510        IDTokenValidator validator = new IDTokenValidator(_iss, _clientID, jwsAlg, _jwkSetURL);
511        IDTokenClaimsSet claims;
512        
513        try
514        {
515            claims = validator.validate(idToken, null);
516        }
517        catch (BadJOSEException e)
518        {
519            getLogger().error("OIDC invalid : issuer, clientId, jwsAlg or jwkSetURL", e);
520            throw new AccessDeniedException("OIDC invalid signature issuer, clientId, jwsAlg or jwkSetURL");
521        }
522        catch (JOSEException e)
523        {
524            getLogger().error("OIDC error while validating token", e);
525            throw new AccessDeniedException("OIDC error while validating token");
526        }
527        
528        return claims;
529    }
530    
531    /**
532     * Request the userInfo using the user info end point and an access token
533     * @param accessToken the access token to retrieve the user info
534     * @return a representation of the user info from the scope requested with the token
535     * @throws IOException if an error occurred while contacting the end point
536     * @throws ParseException if an error occurred while parsing the end point answer
537     */
538    protected UserInfo getUserInfo(AccessToken accessToken) throws IOException, ParseException
539    {
540        HTTPResponse httpResponse = new UserInfoRequest(_userInfoEndpoint, accessToken).toHTTPRequest().send();
541        UserInfoResponse userInfoResponse = UserInfoResponse.parse(httpResponse);
542        
543        if (userInfoResponse.indicatesSuccess())
544        {
545            return userInfoResponse.toSuccessResponse().getUserInfo();
546        }
547        else
548        {
549            String error = userInfoResponse.toErrorResponse().getErrorObject().toJSONObject().toJSONString();
550            getLogger().error("Failed to retrieve the user info. The server indicate the following error :\n" + error);
551            throw new AccessDeniedException("Failed to retrieve the user info. The server indicate the following error :\n" + error);
552        }
553    }
554
555    /**
556     * Compute a user identity based on the user info
557     * @param userInfo the user info
558     * @param request the original request
559     * @param redirector the redirector to use if need be
560     * @return the identified user info or null if no matching user were found
561     * @throws NotUniqueUserException if multiple user matched
562     */
563    protected UserIdentity getUserIdentity(UserInfo userInfo, Request request, Redirector redirector) throws NotUniqueUserException
564    {
565        // get the user email
566        String login = userInfo.getEmailAddress();
567        if (login == null)
568        {
569            getLogger().error("Email not found, connection canceled ");
570            throw new AccessDeniedException("Email not found, connection canceled"); 
571        }
572        
573        // create a UserIdentity from the email
574        UserPopulation userPopulation = _getPopulation(request);
575        UserIdentity user = _getUserIdentity(login, userPopulation);
576    
577        // If we found a UserIdentity, we return it
578        if (user != null)
579        {
580            return user;
581        }
582    
583        // If not, we are going to pre-sign-up the user with its email, firstname and lastname
584        String firstName = userInfo.getGivenName();
585        String lastName = userInfo.getFamilyName();
586        if (firstName == null || lastName == null)
587        {
588            getLogger().info("The fields could not be pre-filled");
589        }
590        
591        // We call the temporarySignup method from the endOfSignupProcess, which will do nothing if it is in the CMS and temporary sign the user up if it is the site 
592        _endOfAuthenticationProcess.unexistingUser(login, firstName, lastName, userPopulation, redirector, request);
593        
594        return null;
595    }
596
597    private UserIdentity _getUserIdentity(String login, UserPopulation userPopulation) throws NotUniqueUserException
598    {
599        User user = null;
600        
601        for (UserDirectory userDirectory : userPopulation.getUserDirectories())
602        {
603            user = userDirectory.getUser(login);
604
605            if (user == null)
606            {
607                // Try to get user by email
608                user = userDirectory.getUserByEmail(login);
609            }
610            
611            if (user != null)
612            {
613                return user.getIdentity();
614            }
615        }
616        
617        return null;
618    }
619}