问题
I have a JSON problem while returning custom attributes in my custom authentication handler's overriden authenticateUsernamePasswordInternal function:
return createHandlerResult( credential,
this.getPrincipalFactory( ).createPrincipal( credential.getId( ), attributes) );
which createPrincipal method accepts Map<String, Object>
Principal createPrincipal(String id, Map<String, Object> attributes);
When I put Map<String, List>
in attributes, CAS returns toString representation of the List instead of its JSON representation. In short, how to return the correct JSON serialization of attributes from this function?
Notes:
- CAS version used: 5.3.8
- Custom Authentication extended via AbstractUsernamePasswordAuthenticationHandler
- JWT is implemented which uses CAS protocol
Here what I tried so far:
1) CAS converts List of HashMap as String while validating the service (may be root cause)
When I create Principal as Map<String, new ArrayList<new HashMap<>>
, my HashMap is converted to toString representation of the HashMap. So It's type information is now turned from HashMap -> String, which makes CAS return not correct JSON to my client because String is serialized as it is for JSON. Here where it happens ->
AbstractUrlBasedTicketValidator -> validate() -> final String serverResponse = retrieveResponseFromServer(new URL(validationUrl), ticket);
Here serverResponse contains:
<cas:serviceResponse xmlns:cas='http://www.yale.edu/tp/cas'>
<cas:authenticationSuccess>
<cas:user>test</cas:user>
<cas:attributes>
<cas:roles>(Test,[ADMIN])</cas:roles>
</cas:attributes>
</cas:authenticationSuccess>
</cas:serviceResponse>
What I expect:
<cas:serviceResponse xmlns:cas='http://www.yale.edu/tp/cas'>
<cas:authenticationSuccess>
<cas:user>test</cas:user>
<cas:attributes>
<cas:roles>
<cas:Test>ADMIN</cas:Test>
</cas:roles>
</cas:attributes>
</cas:authenticationSuccess>
</cas:serviceResponse>
2) Returning new Principal with a dummy Map in the Object for the Map<String, Object>
When I add a HashMap to the Object section of Map<String, Object>
, it returns to the client as {"left": "key", "right": "value"}
for ["key":"value"]
map.
I have been debugging for so long, I see how CAS uses json-smart-2.3 library when I request /tickets URL. I see that when I send Map in the Object of Map for the attributes, json-smart library uses its BeansWriter to serialize Map, which gets fields of the class and uses as keys. So I send HashMap -> CAS converts it to Java Pair (described in next step) -> Pair has attributes "left" and "right", so it adds left and right fields to the JSON body which I don't want.
3) Debugging CAS to understand how it serializes attributes as JSON for API call (token url)
- I look for the CAS for how it handles Principal, it merges everything as LinkedList of Pair's. So for that reason whatever I add in the Object of Map section, it returns as an array in the JSON representation like []. Which means when I add
attributes.put("name", "ExampleName")
, it returns as"{"name":["ExampleName"]}
Because CAS callsmergeAttributes
function ofDefaultAuthenticationResultBuilder
class, everything in the Principal is returned as List in that function which we sent in Principal object creationsMap<String, Object>
. So this means every attribute is returned as List? Also downloaded CAS source code and see that their tests assert like principal.getAttributes()[0] which gives a hint that this is default behavior? I couldn't see any documentation anywhere but doesn't make sense.
4) Returning new Principal with a JSON representation in the Object for the Map<String, Object>
(Almost a solution)
Also I tried directly return JSON representation in the Object section of attributes:
Map<String, Object> attributes = new HashMap<>();
String roles = "{"TestModule":["Name1"]}"; (didn't add escape quotes for simplicity)
attributes.put("roles", roles);
It returns as expected JSON for API calls to the /ticket URL because serialization library tries to serialize String, which I sent So it is a kind of confusing solution but still have some problems. If I login via /login page, CAS wraps every attributes again with []. When I debug I see that this time CAS doesn't use the serializer that it uses when I cal /ticket URL. I tried to debug more but stuck somewhere when CAS started to use cas-server-core-webflow-api
I don't want this:
{"rolesPerModule":["{\"TestModuleForBouncer_LIVE\":[\"ADMIN\"]}"]}
or this:
{"name":[ExampleName]} *(yes, no "" returned here)*
I want like:
{"rolesPerModule":{"{\"TestModuleForBouncer_LIVE\":[\"ADMIN\"]}"}}
or this
{"name":"ExampleName"}
回答1:
Finally, I found the root cause. If you are here and looking for the reason why your Principal attributes have {"left": "key", "right": "value"} instead of["key":"value"]
here I will try to show root cause first and my solution:
Why there are "left" and "right" attributes in my response JSON for requests to /v1/tickets?
1) You return new SimplePrincipal(id, new HashMap)
2) CAS merges all attributes into a collection. You can find it:
DefaultAuthenticationResultBuilder -> mergeAttributes()
then it calls
CollectionUtils.toCollection(entry.getValue(), ArrayList.class)
3) Inside the function look at those lines:
else if (obj instanceof Collection) {
c.addAll((Collection<Object>) obj);
LOGGER.trace("Converting multi-valued attribute [{}]", obj);
} else if (obj instanceof Map) {
final Set<Map.Entry> set = ((Map) obj).entrySet();
c.addAll(set.stream().map(e -> Pair.of(e.getKey(), e.getValue())).collect(Collectors.toSet()));
}
if your attributes are Map, their values are streamed as Pair. So your hashmaps values type is changed to Pair now.
4) Than CAS starts to create your JSON. Look at
JWTTokenTicketBuilder -> buildJwt
function (it is being handled by another class which is JwtBuilder in CAS 6.X versions, but the problem is still same)
5) CAS uses nimbus-jose-jwt (v5.10) to create JWTClaims.
6) nimbus-jose-jwt uses json-smart (v2.3) to return JWTObject.
7) CAS calls object.toJSONString() (function of JWTObject) for serializing your attributes into JSON. This is the part where it happens but it is also related to previous steps that I write in detail.
8) json-smart library doesn't handle Pair types, it uses default writers for the types they don't handle which is the case BeansWriterASM. This writer gets all attributes of the class and use them as keys of your JSON, and their values.
9) So in this case your value "name":"test"
-> turned into "left":"name", "right":"test"
Pairs on step 3 by CAS. Since json-smart doesn't handle Pair classes, it returns this JSON.
Yes, long story but I wanted to share my experiences clearly. json-smart library is not being updated for so long and nimbus-jose-jwt library has a plan to change json-smart library (https://bitbucket.org/connect2id/nimbus-jose-jwt/pull-requests/50/wip-allow-replacing-json-smart-with/diff) in their next releases which then CAS may change it too but it seems long path for both.
Workarounds/Solutions
1) Don't return instances of Map in your SimplePrincipal. Instead, use collections on the root of your attributes. Because as in the step 3 above, CAS doesn't wrap your values with Pair's if your values are the instance of Collection. E.g working example for me is:
final Map<String, Object> test= new HashMap<>( );
test.put( "faultyJSON", yourAttributes); // don't do this
test.put( "properJSON", Collections.singleton( yourAttributes ) ); // make this
return createHandlerResult( credential,
this.getPrincipalFactory( ).createPrincipal( credential.getId( ), test) );
This will make your JSON to have meaningless array on the root but as said before, this is workaround for now.
2) Wrap your attributes with JSONAware class which json-smart library allows you return your own JSONString representation. This is not safe solution since if you change your CAS version and if CAS changed any library implementations than this solution may give you a headache but anyway I will share my working example for this too:
public class JsonWrapper<T> implements JSONAware, Serializable
{
@JsonValue
public T attributes;
public JsonWrapper( T attributes )
{
this.attributes = attributes;
}
@Override public String toJSONString( )
{
String json = "{}";
try
{
json = new ObjectMapper( ).writeValueAsString( attributes );
}
catch ( JsonProcessingException e )
{
LoggerFactory.getLogger( getClass( ) )
.error( "Couldn't map attributes: {}. Returning default: {}", attributes, json );
}
return json;
}
}
This class will return its own JSON represantation when json-smart's serialiazation begins. Also you need to wrap your all attributes with this class like:
yourAttributes.forEach( ( k, v ) -> yourAttributes.put( k, new JsonWrapper<> (v) ) )
return createHandlerResult( credential,
this.getPrincipalFactory( ).createPrincipal( credential.getId( ), yourAttributes) );
3) You can implement your own Writer like JsonPairWriter and register it to JsonWriter's writerList. I tried this one, it works too but it could be dummiest solution compared to the above because of lots of maintanence & buggy side effects, just keep in mind.
Last but not least, this doesn't happen when you call /login endpoint of CAS which means getting token via browser. As I understand so far it has different workflow to return attributes and json instead of the flow I described above. Not sure but service and all attribute etc information is taken via REST call and get some XML response, so this is parsed to the clients.
来源:https://stackoverflow.com/questions/57788482/cas-custom-authentication-handler-principal-json-problem