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