001/*
002 *  Copyright 2021 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.aad;
017
018import java.io.IOException;
019import java.net.URI;
020import java.util.Date;
021import java.util.Map;
022import java.util.Set;
023import java.util.UUID;
024
025import org.apache.avalon.framework.context.Context;
026import org.apache.avalon.framework.context.ContextException;
027import org.apache.avalon.framework.context.Contextualizable;
028import org.apache.avalon.framework.service.ServiceException;
029import org.apache.avalon.framework.service.ServiceManager;
030import org.apache.avalon.framework.service.Serviceable;
031import org.apache.cocoon.ProcessingException;
032import org.apache.cocoon.components.ContextHelper;
033import org.apache.cocoon.environment.ObjectModelHelper;
034import org.apache.cocoon.environment.Redirector;
035import org.apache.cocoon.environment.Request;
036import org.apache.cocoon.environment.Session;
037
038import org.ametys.core.authentication.AbstractCredentialProvider;
039import org.ametys.core.authentication.BlockingCredentialProvider;
040import org.ametys.core.user.UserIdentity;
041import org.ametys.plugins.extrausermgt.authentication.oidc.AbstractOIDCCredentialProvider;
042import org.ametys.runtime.authentication.AccessDeniedException;
043
044import com.microsoft.aad.msal4j.AuthorizationCodeParameters;
045import com.microsoft.aad.msal4j.AuthorizationRequestUrlParameters;
046import com.microsoft.aad.msal4j.AuthorizationRequestUrlParameters.Builder;
047import com.microsoft.aad.msal4j.ClientCredentialFactory;
048import com.microsoft.aad.msal4j.ConfidentialClientApplication;
049import com.microsoft.aad.msal4j.IAccount;
050import com.microsoft.aad.msal4j.IAuthenticationResult;
051import com.microsoft.aad.msal4j.IClientSecret;
052import com.microsoft.aad.msal4j.Prompt;
053import com.microsoft.aad.msal4j.ResponseMode;
054import com.microsoft.aad.msal4j.SilentParameters;
055import com.nimbusds.jwt.JWTClaimsSet;
056import com.nimbusds.jwt.SignedJWT;
057
058/**
059 * Sign in through Azure AD, using the OpenId Connect protocol.
060 */
061public class AADCredentialProvider extends AbstractCredentialProvider implements BlockingCredentialProvider, Contextualizable, Serviceable
062{
063    private Context _context;
064    
065    private String _clientID;
066    private String _clientSecret;
067    private String _tenant;
068    private boolean _prompt;
069    
070    private AzureADScopesExtensionPoint _azureADScopesExtensionPoint;
071    
072    public void service(ServiceManager manager) throws ServiceException
073    {
074        _azureADScopesExtensionPoint = (AzureADScopesExtensionPoint) manager.lookup(AzureADScopesExtensionPoint.ROLE);
075    }
076    
077    @Override
078    public void contextualize(Context context) throws ContextException
079    {
080        _context = context;
081    }
082    
083    @Override
084    public void init(String id, String cpModelId, Map<String, Object> paramValues, String label) throws Exception
085    {
086        super.init(id, cpModelId, paramValues, label);
087        
088        _clientID = (String) paramValues.get("authentication.aad.appid");
089        _clientSecret = (String) paramValues.get("authentication.aad.clientsecret");
090        _tenant = (String) paramValues.get("authentication.aad.tenant");
091        _prompt = (boolean) paramValues.get("authentication.aad.prompt");
092    }
093    
094    private ConfidentialClientApplication _getClient() throws Exception
095    {
096        IClientSecret secret = ClientCredentialFactory.createFromSecret(_clientSecret);
097        ConfidentialClientApplication client = ConfidentialClientApplication.builder(_clientID, secret)
098                                                                            .authority("https://login.microsoftonline.com/" + _tenant)
099                                                                            .build();
100        return client;
101    }
102
103    @Override
104    public boolean blockingIsStillConnected(UserIdentity userIdentity, Redirector redirector) throws Exception
105    {
106        Map objectModel = ContextHelper.getObjectModel(_context);
107        Request request = ObjectModelHelper.getRequest(objectModel);
108        Session session = request.getSession(true);
109        
110        // this check is also done by the following MSAL code, but it's way faster with just a simple date check
111        Date expDat = (Date) session.getAttribute("aad_expirationDate");
112        if (new Date().before(expDat))
113        {
114            return true;
115        }
116        
117        ConfidentialClientApplication client = _getClient();
118        
119        IAccount account = (IAccount) session.getAttribute("aad_account");
120        String tokenCache = (String) session.getAttribute("aad_tokenCache");
121        
122        SilentParameters parameters = SilentParameters.builder(Set.of("openid"), account).build();
123        client.tokenCache().deserialize(tokenCache);
124        IAuthenticationResult result = client.acquireTokenSilently(parameters).get();
125        
126        JWTClaimsSet claimsSet = SignedJWT.parse(result.idToken()).getJWTClaimsSet();
127        
128        session.setAttribute("aad_expirationDate", claimsSet.getExpirationTime());
129        session.setAttribute("aad_tokenCache", client.tokenCache().serialize());
130        session.setAttribute("aad_account", result.account());
131        
132        return true;
133    }
134    
135    @Override
136    public boolean blockingGrantAnonymousRequest()
137    {       
138        return false;
139    }
140    
141    @Override
142    public UserIdentity blockingGetUserIdentity(Redirector redirector) throws Exception
143    {
144        Map objectModel = ContextHelper.getObjectModel(_context);
145        Request request = ObjectModelHelper.getRequest(objectModel);
146        Session session = request.getSession(true);
147        
148        ConfidentialClientApplication client = _getClient();
149        
150        StringBuilder uriBuilder = new StringBuilder();
151        if (request.isSecure())
152        {
153            uriBuilder.append("https://").append(request.getServerName());
154            if (request.getServerPort() != 443)
155            {
156                uriBuilder.append(":");
157                uriBuilder.append(request.getServerPort());
158            }
159        }
160        else
161        {
162            uriBuilder.append("http://").append(request.getServerName());
163            if (request.getServerPort() != 80)
164            {
165                uriBuilder.append(":");
166                uriBuilder.append(request.getServerPort());
167            }
168        }
169        
170        uriBuilder.append(request.getContextPath());
171        uriBuilder.append("/_extra-user-management/oidc-callback");
172        String requestURI = uriBuilder.toString();
173        
174        getLogger().debug("AADCredentialProvider callback URI: {}", requestURI);
175        
176        String code = request.getParameter("code");
177        if (code == null)
178        {
179            // sign-in request: redirect the client through the actual authentication process
180            
181            String state = UUID.randomUUID().toString();
182            session.setAttribute("aad_state", state);
183            
184            String actualRedirectUri = request.getRequestURI();
185            if (request.getQueryString() != null) 
186            {
187                actualRedirectUri += "?" + request.getQueryString();
188            }
189            session.setAttribute(AbstractOIDCCredentialProvider.REDIRECT_URI_SESSION_ATTRIBUTE, actualRedirectUri);
190            
191            String nonce = UUID.randomUUID().toString();
192            session.setAttribute("aad_nonce", nonce);
193            
194            Builder builder = AuthorizationRequestUrlParameters.builder(requestURI, _azureADScopesExtensionPoint.getScopes())
195                                                               .responseMode(ResponseMode.QUERY)
196                                                               .state(state)
197                                                               .nonce(nonce);
198            
199            if (_prompt)
200            {
201                builder.prompt(Prompt.SELECT_ACCOUNT);
202            }
203            
204            AuthorizationRequestUrlParameters parameters = builder.build();
205
206            String authorizationRequestUrl = client.getAuthorizationRequestUrl(parameters).toString();
207            redirector.redirect(false, authorizationRequestUrl);
208            return null;
209        }
210        
211        // we got an authorization code
212        
213        // but first, check the state to prevent CSRF attacks
214        String storedState = (String) session.getAttribute("aad_state");
215        String state = request.getParameter("state");
216        
217        if (!storedState.equals(state))
218        {
219            throw new AccessDeniedException("AAD state mismatch");
220        }
221        
222        session.setAttribute("aad_state", null);
223        
224        // handle errors
225        String error = request.getParameter("error");
226        String errorDescription = request.getParameter("error_description");
227        if (error != null || errorDescription != null) 
228        {
229            throw new AccessDeniedException(String.format("Received an error from AAD. Error: %s %nErrorDescription: %s", error, errorDescription));
230        }
231        
232        // then get the token from the authorization code
233        AuthorizationCodeParameters authParams = AuthorizationCodeParameters.builder(code, new URI(requestURI))
234                                                                            .scopes(_azureADScopesExtensionPoint.getScopes())
235                                                                            .build();
236        
237        IAuthenticationResult result = client.acquireToken(authParams).get();
238        
239        // parse the token
240        JWTClaimsSet claimsSet = SignedJWT.parse(result.idToken()).getJWTClaimsSet();
241        Map<String, Object> tokenClaims = claimsSet.getClaims();
242        
243        String storedNonce = (String) session.getAttribute("aad_nonce");
244        String nonce = (String) tokenClaims.get("nonce");
245        
246        if (!storedNonce.equals(nonce))
247        {
248            throw new AccessDeniedException("AAD nonce mismatch");
249        }
250        
251        session.setAttribute("aad_nonce", null);
252        
253        session.setAttribute("aad_expirationDate", claimsSet.getExpirationTime());
254        session.setAttribute("aad_tokenCache", client.tokenCache().serialize());
255        session.setAttribute("aad_account", result.account());
256        
257        session.setAttribute(AbstractOIDCCredentialProvider.TOKEN_SESSION_ATTRIBUTE, result.accessToken());
258        
259        // then the user is finally logged in
260        String login = result.account().username();
261        
262        return new UserIdentity(login, null);
263    }
264    
265    @Override
266    public void blockingUserNotAllowed(Redirector redirector)
267    {
268        // Nothing to do.
269    }
270    
271    @Override
272    public void blockingUserAllowed(UserIdentity userIdentity, Redirector redirector) throws ProcessingException, IOException
273    {
274        Map objectModel = ContextHelper.getObjectModel(_context);
275        Request request = ObjectModelHelper.getRequest(objectModel);
276        Session session = request.getSession(true);
277        
278        String redirectUri = (String) session.getAttribute(AbstractOIDCCredentialProvider.REDIRECT_URI_SESSION_ATTRIBUTE);
279        redirector.redirect(true, redirectUri);
280    }
281    
282    public boolean requiresNewWindow()
283    {
284        return true;
285    }
286}