View Javadoc
1   /*
2    * Licensed to the Apache Software Foundation (ASF) under one
3    * or more contributor license agreements.  See the NOTICE file
4    * distributed with this work for additional information
5    * regarding copyright ownership.  The ASF licenses this file
6    * to you under the Apache License, Version 2.0 (the
7    * "License"); you may not use this file except in compliance
8    * with the License.  You may obtain a copy of the License at
9    *
10   *   http://www.apache.org/licenses/LICENSE-2.0
11   *
12   * Unless required by applicable law or agreed to in writing,
13   * software distributed under the License is distributed on an
14   * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
15   * KIND, either express or implied.  See the License for the
16   * specific language governing permissions and limitations
17   * under the License.
18   */
19  package org.apache.syncope.core.logic;
20  
21  import com.nimbusds.jwt.JWTClaimsSet;
22  import com.nimbusds.jwt.SignedJWT;
23  import com.nimbusds.oauth2.sdk.AuthorizationCode;
24  import java.lang.reflect.Method;
25  import java.text.ParseException;
26  import java.time.OffsetDateTime;
27  import java.util.HashMap;
28  import java.util.List;
29  import java.util.Map;
30  import java.util.Optional;
31  import org.apache.commons.lang3.StringUtils;
32  import org.apache.commons.lang3.tuple.Pair;
33  import org.apache.syncope.common.lib.Attr;
34  import org.apache.syncope.common.lib.SyncopeClientException;
35  import org.apache.syncope.common.lib.oidc.OIDCLoginResponse;
36  import org.apache.syncope.common.lib.oidc.OIDCRequest;
37  import org.apache.syncope.common.lib.to.EntityTO;
38  import org.apache.syncope.common.lib.to.Item;
39  import org.apache.syncope.common.lib.to.UserTO;
40  import org.apache.syncope.common.lib.types.CipherAlgorithm;
41  import org.apache.syncope.common.lib.types.ClientExceptionType;
42  import org.apache.syncope.common.lib.types.IdRepoEntitlement;
43  import org.apache.syncope.core.logic.oidc.NoOpSessionStore;
44  import org.apache.syncope.core.logic.oidc.OIDCC4UIContext;
45  import org.apache.syncope.core.logic.oidc.OIDCClientCache;
46  import org.apache.syncope.core.logic.oidc.OIDCUserManager;
47  import org.apache.syncope.core.persistence.api.dao.NotFoundException;
48  import org.apache.syncope.core.persistence.api.dao.OIDCC4UIProviderDAO;
49  import org.apache.syncope.core.persistence.api.entity.OIDCC4UIProvider;
50  import org.apache.syncope.core.provisioning.api.data.AccessTokenDataBinder;
51  import org.apache.syncope.core.provisioning.api.serialization.POJOHelper;
52  import org.apache.syncope.core.spring.security.AuthContextUtils;
53  import org.apache.syncope.core.spring.security.AuthDataAccessor;
54  import org.apache.syncope.core.spring.security.Encryptor;
55  import org.pac4j.core.exception.http.WithLocationAction;
56  import org.pac4j.oidc.client.OidcClient;
57  import org.pac4j.oidc.credentials.OidcCredentials;
58  import org.pac4j.oidc.profile.OidcProfile;
59  import org.springframework.security.access.prepost.PreAuthorize;
60  
61  public class OIDCC4UILogic extends AbstractTransactionalLogic<EntityTO> {
62  
63      protected static final String JWT_CLAIM_OP_NAME = "OP_NAME";
64  
65      protected static final String JWT_CLAIM_ID_TOKEN = "ID_TOKEN";
66  
67      protected static final Encryptor ENCRYPTOR = Encryptor.getInstance();
68  
69      protected final OIDCClientCache oidcClientCacheLogin;
70  
71      protected final OIDCClientCache oidcClientCacheLogout;
72  
73      protected final AuthDataAccessor authDataAccessor;
74  
75      protected final AccessTokenDataBinder accessTokenDataBinder;
76  
77      protected final OIDCC4UIProviderDAO opDAO;
78  
79      protected final OIDCUserManager userManager;
80  
81      public OIDCC4UILogic(
82              final OIDCClientCache oidcClientCacheLogin,
83              final OIDCClientCache oidcClientCacheLogout,
84              final AuthDataAccessor authDataAccessor,
85              final AccessTokenDataBinder accessTokenDataBinder,
86              final OIDCC4UIProviderDAO opDAO,
87              final OIDCUserManager userManager) {
88  
89          this.oidcClientCacheLogin = oidcClientCacheLogin;
90          this.oidcClientCacheLogout = oidcClientCacheLogout;
91          this.authDataAccessor = authDataAccessor;
92          this.accessTokenDataBinder = accessTokenDataBinder;
93          this.opDAO = opDAO;
94          this.userManager = userManager;
95      }
96  
97      protected OidcClient getOidcClient(
98              final OIDCClientCache oidcClientCache,
99              final OIDCC4UIProvider op,
100             final String callbackUrl) {
101 
102         return oidcClientCache.get(op.getName()).orElseGet(() -> oidcClientCache.add(op, callbackUrl));
103     }
104 
105     @PreAuthorize("hasRole('" + IdRepoEntitlement.ANONYMOUS + "')")
106     public OIDCRequest createLoginRequest(final String redirectURI, final String opName) {
107         // 0. look for OP
108         OIDCC4UIProvider op = Optional.ofNullable(opDAO.findByName(opName)).
109                 orElseThrow(() -> new NotFoundException("OIDC Provider '" + opName + '\''));
110 
111         // 1. look for OidcClient
112         OidcClient oidcClient = getOidcClient(oidcClientCacheLogin, op, redirectURI);
113 
114         // 2. create OIDCRequest
115         WithLocationAction action = oidcClient.getRedirectionAction(new OIDCC4UIContext(), NoOpSessionStore.INSTANCE).
116                 map(WithLocationAction.class::cast).
117                 orElseThrow(() -> {
118                     SyncopeClientException sce = SyncopeClientException.build(ClientExceptionType.Unknown);
119                     sce.getElements().add("No RedirectionAction generated for LoginRequest");
120                     return sce;
121                 });
122 
123         OIDCRequest loginRequest = new OIDCRequest();
124         loginRequest.setLocation(action.getLocation());
125         return loginRequest;
126     }
127 
128     @PreAuthorize("hasRole('" + IdRepoEntitlement.ANONYMOUS + "')")
129     public OIDCLoginResponse login(final String redirectURI, final String authorizationCode, final String opName) {
130         // 0. look for OP
131         OIDCC4UIProvider op = Optional.ofNullable(opDAO.findByName(opName)).
132                 orElseThrow(() -> new NotFoundException("OIDC Provider '" + opName + '\''));
133 
134         // 1. look for configured client
135         OidcClient oidcClient = getOidcClient(oidcClientCacheLogin, op, redirectURI);
136 
137         // 2. get OpenID Connect tokens
138         String idTokenHint;
139         JWTClaimsSet idToken;
140         try {
141             OidcCredentials credentials = new OidcCredentials();
142             credentials.setCode(new AuthorizationCode(authorizationCode));
143 
144             oidcClient.getAuthenticator().validate(credentials, new OIDCC4UIContext(), NoOpSessionStore.INSTANCE);
145 
146             idToken = credentials.getIdToken().getJWTClaimsSet();
147             idTokenHint = credentials.getIdToken().serialize();
148         } catch (Exception e) {
149             LOG.error("While validating Token Response", e);
150             SyncopeClientException sce = SyncopeClientException.build(ClientExceptionType.Unknown);
151             sce.getElements().add(e.getMessage());
152             throw sce;
153         }
154 
155         // 3. prepare the result
156         OIDCLoginResponse loginResp = new OIDCLoginResponse();
157         loginResp.setLogoutSupported(StringUtils.isNotBlank(op.getEndSessionEndpoint()));
158 
159         // 3a. find matching user (if any) and return the received attributes
160         String keyValue = idToken.getSubject();
161         for (Item item : op.getItems()) {
162             Attr attrTO = new Attr();
163             attrTO.setSchema(item.getExtAttrName());
164 
165             String value = Optional.ofNullable(idToken.getClaim(item.getExtAttrName())).
166                     map(Object::toString).
167                     orElse(null);
168             if (value != null) {
169                 attrTO.getValues().add(value);
170                 loginResp.getAttrs().add(attrTO);
171                 if (item.isConnObjectKey()) {
172                     keyValue = value;
173                 }
174             }
175         }
176 
177         List<String> matchingUsers = Optional.ofNullable(keyValue).
178                 map(k -> userManager.findMatchingUser(k, op.getConnObjectKeyItem().get())).
179                 orElse(List.of());
180         LOG.debug("Found {} matching users for {}", matchingUsers.size(), keyValue);
181 
182         // 3b. not found: create or selfreg if configured
183         String username;
184         if (matchingUsers.isEmpty()) {
185             if (op.isCreateUnmatching()) {
186                 LOG.debug("No user matching {}, about to create", keyValue);
187 
188                 String defaultUsername = keyValue;
189                 username = AuthContextUtils.callAsAdmin(AuthContextUtils.getDomain(),
190                         () -> userManager.create(op, loginResp, defaultUsername));
191             } else if (op.isSelfRegUnmatching()) {
192                 UserTO userTO = new UserTO();
193 
194                 userManager.fill(op, loginResp, userTO);
195 
196                 loginResp.getAttrs().clear();
197                 loginResp.getAttrs().addAll(userTO.getPlainAttrs());
198                 if (StringUtils.isNotBlank(userTO.getUsername())) {
199                     loginResp.setUsername(userTO.getUsername());
200                 } else {
201                     loginResp.setUsername(keyValue);
202                 }
203 
204                 loginResp.setSelfReg(true);
205 
206                 return loginResp;
207             } else {
208                 throw new NotFoundException(Optional.ofNullable(keyValue).
209                         map(value -> "User matching the provided value " + value).
210                         orElse("User marching the provided claims"));
211             }
212         } else if (matchingUsers.size() > 1) {
213             throw new IllegalArgumentException("Several users match the provided value " + keyValue);
214         } else {
215             if (op.isUpdateMatching()) {
216                 LOG.debug("About to update {} for {}", matchingUsers.get(0), keyValue);
217 
218                 username = AuthContextUtils.callAsAdmin(AuthContextUtils.getDomain(),
219                         () -> userManager.update(matchingUsers.get(0), op, loginResp));
220             } else {
221                 username = matchingUsers.get(0);
222             }
223         }
224 
225         loginResp.setUsername(username);
226 
227         // 4. generate JWT for further access
228         Map<String, Object> claims = new HashMap<>();
229         claims.put(JWT_CLAIM_OP_NAME, opName);
230         claims.put(JWT_CLAIM_ID_TOKEN, idTokenHint);
231 
232         byte[] authorities = null;
233         try {
234             authorities = ENCRYPTOR.encode(POJOHelper.serialize(
235                     authDataAccessor.getAuthorities(loginResp.getUsername(), null)), CipherAlgorithm.AES).
236                     getBytes();
237         } catch (Exception e) {
238             LOG.error("Could not fetch authorities", e);
239         }
240 
241         Pair<String, OffsetDateTime> accessTokenInfo =
242                 accessTokenDataBinder.create(loginResp.getUsername(), claims, authorities, true);
243         loginResp.setAccessToken(accessTokenInfo.getLeft());
244         loginResp.setAccessTokenExpiryTime(accessTokenInfo.getRight());
245 
246         return loginResp;
247     }
248 
249     @PreAuthorize("isAuthenticated() and not(hasRole('" + IdRepoEntitlement.ANONYMOUS + "'))")
250     public OIDCRequest createLogoutRequest(final String accessToken, final String redirectURI) {
251         // 0. fetch the current JWT used for Syncope authentication
252         JWTClaimsSet claimsSet;
253         try {
254             SignedJWT jwt = SignedJWT.parse(accessToken);
255             claimsSet = jwt.getJWTClaimsSet();
256         } catch (ParseException e) {
257             SyncopeClientException sce = SyncopeClientException.build(ClientExceptionType.InvalidAccessToken);
258             sce.getElements().add(e.getMessage());
259             throw sce;
260         }
261 
262         // 1. look for OidcClient
263         OIDCC4UIProvider op = Optional.ofNullable(opDAO.findByName((String) claimsSet.getClaim(JWT_CLAIM_OP_NAME))).
264                 orElseThrow(() -> new NotFoundException(""
265                 + "OIDC Provider '" + claimsSet.getClaim(JWT_CLAIM_OP_NAME) + '\''));
266         OidcClient oidcClient = getOidcClient(oidcClientCacheLogout, op, redirectURI);
267 
268         // 2. create OIDCRequest
269         OidcProfile profile = new OidcProfile();
270         profile.setIdTokenString((String) claimsSet.getClaim(JWT_CLAIM_ID_TOKEN));
271 
272         WithLocationAction action = oidcClient.getLogoutAction(
273                 new OIDCC4UIContext(),
274                 NoOpSessionStore.INSTANCE,
275                 profile,
276                 redirectURI).
277                 map(WithLocationAction.class::cast).
278                 orElseThrow(() -> {
279                     SyncopeClientException sce = SyncopeClientException.build(ClientExceptionType.Unknown);
280                     sce.getElements().add("No RedirectionAction generated for LogoutRequest");
281                     return sce;
282                 });
283 
284         OIDCRequest logoutRequest = new OIDCRequest();
285         logoutRequest.setLocation(action.getLocation());
286         return logoutRequest;
287     }
288 
289     @Override
290     protected EntityTO resolveReference(
291             final Method method, final Object... args) throws UnresolvedReferenceException {
292 
293         throw new UnresolvedReferenceException();
294     }
295 }