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    private URI _buildAbsoluteURI(Request request, String path)
324    {
325        StringBuilder uriBuilder = new StringBuilder()
326            .append(request.getScheme())
327            .append("://")
328            .append(request.getServerName());
329        
330        if (request.isSecure())
331        {
332            if (request.getServerPort() != 443)
333            {
334                uriBuilder.append(":");
335                uriBuilder.append(request.getServerPort());
336            }
337        }
338        else
339        {
340            if (request.getServerPort() != 80)
341            {
342                uriBuilder.append(":");
343                uriBuilder.append(request.getServerPort());
344            }
345        }
346
347        uriBuilder.append(request.getContextPath());
348        uriBuilder.append(path);
349        
350        return URI.create(uriBuilder.toString());
351    }
352    
353    /** 
354     * Sign the user in by sending an authentication request to the issuer
355     * @param redirector The redirector
356     * @param redirectUri The redirect URI
357     * @param session The current session
358     * @throws ProcessingException If an error occurs
359     * @throws IOException If an error occurs
360     */
361    protected void signIn(Redirector redirector, URI redirectUri, Session session) throws ProcessingException, IOException
362    {
363        // sign-in request: redirect the client through the actual authentication process
364        
365        // creation of the state used to secure the process
366        State state = new State();
367        session.setAttribute(STATE_SESSION_ATTRIBUTE, state);
368
369        // compose the request
370        AuthenticationRequest authenticationRequest = new AuthenticationRequest(_authUri, new ResponseType(ResponseType.Value.CODE), _scope, _clientID, redirectUri, state, null);
371        
372        String authReqURI = authenticationRequest.toURI().toString();
373        authReqURI += "&access_type=offline";
374        
375        redirector.redirect(false, authReqURI);
376    }
377    
378    /** 
379     * Checks the State parameter of the request to prevent CSRF attacks
380     * @throws AccessDeniedException If an error occurs
381     */
382    protected void checkState() throws AccessDeniedException
383    {
384        Request request = ContextHelper.getRequest(_context);
385        Session session = request.getSession(true);
386        String storedState = session.getAttribute(STATE_SESSION_ATTRIBUTE).toString();
387        String stateRequest = request.getParameter("state");
388        
389        if (!storedState.equals(stateRequest))
390        {
391            getLogger().error("OIDC state mismatch. Method checkState of AbstractOIDCCredentialProvider");
392            throw new AccessDeniedException("OIDC state mismatch");
393        }
394        
395        session.setAttribute(STATE_SESSION_ATTRIBUTE, null);
396    }
397    
398    /** 
399     * Request the tokens (ID token and Access token)
400     * @param authCode The authorization code from the authentication request
401     * @param redirectUri The redirect URI
402     * @return The <code>OIDCTokens</code> that contains the access token and the id token
403     * @throws AccessDeniedException If an error occurs
404     */
405    protected OIDCTokens requestToken(AuthorizationCode authCode, URI redirectUri) throws AccessDeniedException
406    {
407        // token request: checking if the user is known 
408        TokenRequest tokenReq = new TokenRequest(_tokenEndpointUri, getClientAuthentication(), new AuthorizationCodeGrant(authCode, redirectUri));
409        // sending request
410        HTTPResponse tokenHTTPResp = null;
411        try
412        {
413            tokenHTTPResp = tokenReq.toHTTPRequest().send();
414        }
415        catch (SerializeException | IOException e)
416        {
417            getLogger().error("OIDC token request failed ", e);
418            throw new AccessDeniedException("OIDC token request failed");
419        }
420
421        // cast the HTTPResponse to TokenResponse
422        TokenResponse tokenResponse = null;
423        try
424        {
425            tokenResponse = OIDCTokenResponseParser.parse(tokenHTTPResp);
426        }
427        catch (ParseException e)
428        {
429            getLogger().error("OIDC token request result invalid ", e);
430            throw new AccessDeniedException("OIDC token request result invalid");
431        }
432
433        if (tokenResponse instanceof TokenErrorResponse)
434        {
435            getLogger().error("OIDC token request invalid token response instance of TokenErrorResponse in method requestToken from AbstractOIDCCredentialProvider");
436            throw new AccessDeniedException("OIDC token request result invalid");
437        }
438
439        // get the tokens
440        OIDCTokenResponse  accessTokenResponse = (OIDCTokenResponse) tokenResponse;
441        
442        return accessTokenResponse.getOIDCTokens();
443    }
444    
445    /** 
446     * Request the tokens using a refresh token 
447     * @param clientAuth The client authentication
448     * @param refreshTokenGrant The refreshtokenGrant 
449     * @return The <code>OIDCTokens</code> that contains the access token and the id token
450     * @throws AccessDeniedException If an error occurs
451     * @throws URISyntaxException If an error occurs
452     */
453    protected OIDCTokens requestToken(ClientAuthentication clientAuth, AuthorizationGrant refreshTokenGrant) throws AccessDeniedException, URISyntaxException
454    {
455        // token request: checking if the user is known 
456        TokenRequest tokenReq = new TokenRequest(_tokenEndpointUri, clientAuth, refreshTokenGrant);
457        // sending request
458       
459        HTTPResponse tokenHTTPResp = null;
460        try
461        {
462            tokenHTTPResp = tokenReq.toHTTPRequest().send();
463        }
464        catch (SerializeException | IOException e)
465        {
466            getLogger().error("OIDC token request failed ", e);
467            throw new AccessDeniedException("OIDC token request failed");
468        }
469
470        // cast the HTTPResponse to TokenResponse
471        TokenResponse tokenResponse = null;
472        try
473        {
474            tokenResponse = OIDCTokenResponseParser.parse(tokenHTTPResp);
475        }
476        catch (ParseException e)
477        {
478            getLogger().error("OIDC token request result invalid ", e);
479            throw new AccessDeniedException("OIDC token request result invalid");
480        }
481
482        if (tokenResponse instanceof TokenErrorResponse)
483        {
484            getLogger().error("OIDC token request result invalid: tokenResponse instance of TokenErrorResponse in method requestToken from AbstractOIDCCredentialProvider");
485            throw new AccessDeniedException("OIDC token request result invalid");
486        }
487
488        // get the tokens
489        OIDCTokenResponse  accessTokenResponse = (OIDCTokenResponse) tokenResponse;
490        
491        return accessTokenResponse.getOIDCTokens();
492    }
493    
494    /** 
495     * Validate the id token from the token request
496     * @param idToken The id token from the token request
497     * @return The <code>IDTokenClaimsSet</code> that contains information on the connection such as the expiration time
498     * @throws AccessDeniedException If an error occurs
499     */
500    protected IDTokenClaimsSet validateIdToken(JWT idToken) throws AccessDeniedException
501    {
502        JWSAlgorithm jwsAlg = JWSAlgorithm.RS256;
503        // create validator for signed ID tokens
504        IDTokenValidator validator = new IDTokenValidator(_iss, _clientID, jwsAlg, _jwkSetURL);
505        IDTokenClaimsSet claims;
506        
507        try
508        {
509            claims = validator.validate(idToken, null);
510        }
511        catch (BadJOSEException e)
512        {
513            getLogger().error("OIDC invalid : issuer, clientId, jwsAlg or jwkSetURL", e);
514            throw new AccessDeniedException("OIDC invalid signature issuer, clientId, jwsAlg or jwkSetURL");
515        }
516        catch (JOSEException e)
517        {
518            getLogger().error("OIDC error while validating token", e);
519            throw new AccessDeniedException("OIDC error while validating token");
520        }
521        
522        return claims;
523    }
524    
525    /**
526     * Request the userInfo using the user info end point and an access token
527     * @param accessToken the access token to retrieve the user info
528     * @return a representation of the user info from the scope requested with the token
529     * @throws IOException if an error occurred while contacting the end point
530     * @throws ParseException if an error occurred while parsing the end point answer
531     */
532    protected UserInfo getUserInfo(AccessToken accessToken) throws IOException, ParseException
533    {
534        HTTPResponse httpResponse = new UserInfoRequest(_userInfoEndpoint, accessToken).toHTTPRequest().send();
535        UserInfoResponse userInfoResponse = UserInfoResponse.parse(httpResponse);
536        
537        if (userInfoResponse.indicatesSuccess())
538        {
539            return userInfoResponse.toSuccessResponse().getUserInfo();
540        }
541        else
542        {
543            String error = userInfoResponse.toErrorResponse().getErrorObject().toJSONObject().toJSONString();
544            getLogger().error("Failed to retrieve the user info. The server indicate the following error :\n" + error);
545            throw new AccessDeniedException("Failed to retrieve the user info. The server indicate the following error :\n" + error);
546        }
547    }
548
549    /**
550     * Compute a user identity based on the user info
551     * @param userInfo the user info
552     * @param request the original request
553     * @param redirector the redirector to use if need be
554     * @return the identified user info or null if no matching user were found
555     * @throws NotUniqueUserException if multiple user matched
556     */
557    protected UserIdentity getUserIdentity(UserInfo userInfo, Request request, Redirector redirector) throws NotUniqueUserException
558    {
559        // get the user email
560        String login = userInfo.getEmailAddress();
561        if (login != null)
562        {
563            getLogger().error("Email not found, connection canceled ");
564            throw new AccessDeniedException("Email not found, connection canceled"); 
565        }
566        
567        // create a UserIdentity from the email
568        UserPopulation userPopulation = _getPopulation(request);
569        UserIdentity user = _getUserIdentity(login, userPopulation);
570    
571        // If we found a UserIdentity, we return it
572        if (user != null)
573        {
574            return user;
575        }
576    
577        // If not, we are going to pre-sign-up the user with its email, firstname and lastname
578        String firstName = userInfo.getGivenName();
579        String lastName = userInfo.getFamilyName();
580        if (firstName == null || lastName == null)
581        {
582            getLogger().info("The fields could not be pre-filled");
583        }
584        
585        // 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 
586        _endOfAuthenticationProcess.unexistingUser(login, firstName, lastName, userPopulation, redirector, request);
587        
588        return null;
589    }
590
591    private UserIdentity _getUserIdentity(String login, UserPopulation userPopulation) throws NotUniqueUserException
592    {
593        User user = null;
594        
595        for (UserDirectory userDirectory : userPopulation.getUserDirectories())
596        {
597            user = userDirectory.getUser(login);
598
599            if (user == null)
600            {
601                // Try to get user by email
602                user = userDirectory.getUserByEmail(login);
603            }
604            
605            if (user != null)
606            {
607                return user.getIdentity();
608            }
609        }
610        
611        return null;
612    }
613}