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