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}