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}