Repository navigation
Expand file tree
/
Copy pathSamlLogoutTest.java
More file actions
481 lines (393 loc) · 18.2 KB
/
Copy pathSamlLogoutTest.java
File metadata and controls
481 lines (393 loc) · 18.2 KB
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
package utils;
import static org.hamcrest.MatcherAssert.assertThat;
import static org.hamcrest.Matchers.containsString;
import static org.hamcrest.Matchers.startsWith;
import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.junit.jupiter.api.Assertions.assertFalse;
import static org.junit.jupiter.api.Assertions.assertTrue;
import java.security.KeyPair;
import java.security.cert.X509Certificate;
import java.time.Duration;
import java.time.Instant;
import java.util.Arrays;
import java.util.Properties;
import org.junit.jupiter.api.AfterEach;
import org.junit.jupiter.api.BeforeAll;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;
import org.opensaml.saml.saml2.core.LogoutRequest;
import org.opensaml.saml.saml2.core.NameIDType;
import org.springframework.mock.web.MockHttpServletRequest;
import org.springframework.mock.web.MockHttpServletResponse;
import org.springframework.mock.web.MockHttpSession;
/** Single logout service: which logout messages are trusted, and which sessions they end */
public class SamlLogoutTest {
private static SamlTestSupport saml;
private static KeyPair attackerKeys;
private static X509Certificate attackerCert;
private MockHttpSession loggedIn;
private MockHttpSession otherUser;
@BeforeAll
public static void generateKeys() throws Exception {
saml = new SamlTestSupport();
attackerKeys = SamlTestSupport.newKeyPair();
attackerCert = SamlTestSupport.selfSigned(attackerKeys, "idp.test");
}
/** Installs a client that requires signed logout messages, as the sample configuration does */
@BeforeEach
public void setUp() throws Exception {
SamlSso.clearRememberedLogoutRequests();
SamlTestSupport.install(signedLogoutProperties(true));
loggedIn = new MockHttpSession();
register(loggedIn, "user-123", "_session-1");
otherUser = new MockHttpSession();
register(otherUser, "user-456", "_session-2");
}
@AfterEach
public void tearDown() {
for (MockHttpSession session : new MockHttpSession[] {loggedIn, otherUser}) {
if (!session.isInvalid()) {
session.invalidate();
}
}
SamlTestSupport.uninstallClient();
SamlSso.maxRememberedLogoutRequests = 10000;
SamlSso.clearRememberedLogoutRequests();
}
/** Registers a session for a persistent NameID with default qualifiers, as ACS would */
private static void register(MockHttpSession session, String nameId, String sessionIndex) {
SamlSessionRegistry.register(
session, SamlSso.nameIdKey(nameId, NameIDType.PERSISTENT, null, null), sessionIndex);
}
private static Properties signedLogoutProperties(boolean wantMessagesSigned) throws Exception {
Properties props = saml.properties();
props.setProperty(SamlSso.WANT_MESSAGES_SIGNED, Boolean.toString(wantMessagesSigned));
return props;
}
private static SamlSso.LogoutMessage process(MockHttpServletRequest request) {
return SamlSso.processLogoutMessage(request, new MockHttpServletResponse());
}
private String logoutResponseXml() throws Exception {
return SamlTestSupport.toXml(saml.logoutResponse(), null, null);
}
// LogoutResponse (end of an SP-initiated logout)
@Test
public void redirectLogoutResponse_SignedByIdp_IsAccepted() throws Exception {
String query = SamlTestSupport.redirectQuery("SAMLResponse", logoutResponseXml(), saml.idpKeys);
assertEquals(SamlSso.LogoutMessage.LOGOUT_RESPONSE, process(SamlTestSupport.slsGet(query)));
}
@Test
public void redirectLogoutResponse_Unsigned_IsRejected() throws Exception {
String query = SamlTestSupport.redirectQuery("SAMLResponse", logoutResponseXml(), null);
assertEquals(SamlSso.LogoutMessage.INVALID, process(SamlTestSupport.slsGet(query)));
}
@Test
public void redirectLogoutResponse_SignedByUnknownKey_IsRejected() throws Exception {
String query = SamlTestSupport.redirectQuery("SAMLResponse", logoutResponseXml(), attackerKeys);
assertEquals(SamlSso.LogoutMessage.INVALID, process(SamlTestSupport.slsGet(query)));
}
@Test
public void redirectLogoutResponse_MessageSwappedAfterSigning_IsRejected() throws Exception {
String signed =
SamlTestSupport.redirectQuery("SAMLResponse", logoutResponseXml(), saml.idpKeys);
String otherMessage = SamlTestSupport.redirectQuery("SAMLResponse", logoutResponseXml(), null);
String tampered = otherMessage + signed.substring(signed.indexOf("&SigAlg="));
assertEquals(SamlSso.LogoutMessage.INVALID, process(SamlTestSupport.slsGet(tampered)));
}
@Test
public void postLogoutResponse_XmlSignedByIdp_IsAccepted() throws Exception {
String xml = SamlTestSupport.toXml(saml.logoutResponse(), saml.idpKeys, saml.idpCert);
assertEquals(
SamlSso.LogoutMessage.LOGOUT_RESPONSE,
process(SamlTestSupport.slsPost("SAMLResponse", xml)));
}
@Test
public void postLogoutResponse_Unsigned_IsRejected() throws Exception {
assertEquals(
SamlSso.LogoutMessage.INVALID,
process(SamlTestSupport.slsPost("SAMLResponse", logoutResponseXml())));
}
@Test
public void postLogoutResponse_XmlSignedByUnknownKey_IsRejected() throws Exception {
String xml = SamlTestSupport.toXml(saml.logoutResponse(), attackerKeys, attackerCert);
assertEquals(
SamlSso.LogoutMessage.INVALID, process(SamlTestSupport.slsPost("SAMLResponse", xml)));
}
@Test
public void logoutResponse_EndsNoSession() throws Exception {
String query = SamlTestSupport.redirectQuery("SAMLResponse", logoutResponseXml(), saml.idpKeys);
MockHttpServletRequest request = SamlTestSupport.slsGet(query);
request.setSession(loggedIn);
process(request);
assertFalse(loggedIn.isInvalid());
assertFalse(otherUser.isInvalid());
}
@Test
public void unsignedLogoutResponse_AcceptedWhenSignaturesNotRequired() throws Exception {
SamlTestSupport.install(signedLogoutProperties(false));
String query = SamlTestSupport.redirectQuery("SAMLResponse", logoutResponseXml(), null);
assertEquals(SamlSso.LogoutMessage.LOGOUT_RESPONSE, process(SamlTestSupport.slsGet(query)));
}
// IdP-initiated LogoutRequest
private String logoutRequestQuery(String nameId, String sessionIndex, KeyPair keys)
throws Exception {
String xml = SamlTestSupport.toXml(saml.logoutRequest(nameId, sessionIndex), null, null);
return SamlTestSupport.redirectQuery("SAMLRequest", xml, keys);
}
@Test
public void idpLogoutRequest_EndsOnlyTheNamedSession_RegardlessOfBrowserSession()
throws Exception {
MockHttpServletRequest request =
SamlTestSupport.slsGet(logoutRequestQuery("user-123", "_session-1", saml.idpKeys));
// Delivered through a different user's browser
request.setSession(otherUser);
MockHttpServletResponse response = new MockHttpServletResponse();
SamlSso.LogoutMessage result = SamlSso.processLogoutMessage(request, response);
assertEquals(SamlSso.LogoutMessage.IDP_LOGOUT_REQUEST, result);
assertTrue(loggedIn.isInvalid());
assertFalse(otherUser.isInvalid());
assertThat(response.getRedirectedUrl(), startsWith(SamlTestSupport.IDP_SLO_URL + "?"));
assertThat(response.getRedirectedUrl(), containsString("SAMLResponse="));
}
@Test
public void idpLogoutRequest_WithoutSessionCookie_StillEndsTheNamedSession() throws Exception {
MockHttpServletRequest request =
SamlTestSupport.slsGet(logoutRequestQuery("user-123", "_session-1", saml.idpKeys));
assertEquals(SamlSso.LogoutMessage.IDP_LOGOUT_REQUEST, process(request));
assertTrue(loggedIn.isInvalid());
}
@Test
public void idpLogoutRequest_DifferentSessionIndex_EndsNothing() throws Exception {
MockHttpServletRequest request =
SamlTestSupport.slsGet(logoutRequestQuery("user-123", "_other-login", saml.idpKeys));
request.setSession(loggedIn);
process(request);
assertFalse(loggedIn.isInvalid());
}
@Test
public void idpLogoutRequest_Unsigned_IsRejectedAndEndsNothing() throws Exception {
MockHttpServletRequest request =
SamlTestSupport.slsGet(logoutRequestQuery("user-123", "_session-1", null));
assertEquals(SamlSso.LogoutMessage.INVALID, process(request));
assertFalse(loggedIn.isInvalid());
}
@Test
public void idpLogoutRequest_SignedByUnknownKey_IsRejectedAndEndsNothing() throws Exception {
MockHttpServletRequest request =
SamlTestSupport.slsGet(logoutRequestQuery("user-123", "_session-1", attackerKeys));
assertEquals(SamlSso.LogoutMessage.INVALID, process(request));
assertFalse(loggedIn.isInvalid());
}
@Test
public void postIdpLogoutRequest_XmlSignedByIdp_EndsTheNamedSession() throws Exception {
String xml =
SamlTestSupport.toXml(
saml.logoutRequest("user-123", "_session-1"), saml.idpKeys, saml.idpCert);
assertEquals(
SamlSso.LogoutMessage.IDP_LOGOUT_REQUEST,
process(SamlTestSupport.slsPost("SAMLRequest", xml)));
assertTrue(loggedIn.isInvalid());
assertFalse(otherUser.isInvalid());
}
@Test
public void unsignedIdpLogoutRequestWithoutSessionIndex_EndsNothing() throws Exception {
// Even when signatures are not required, a bare NameID is too guessable to end sessions
SamlTestSupport.install(signedLogoutProperties(false));
MockHttpServletRequest request =
SamlTestSupport.slsGet(logoutRequestQuery("user-123", null, null));
process(request);
assertFalse(loggedIn.isInvalid());
}
@Test
public void idpLogoutRequest_EndsEverySessionIndexItNames() throws Exception {
MockHttpSession secondLogin = new MockHttpSession();
register(secondLogin, "user-123", "_session-3");
String xml =
SamlTestSupport.toXml(
saml.logoutRequest("user-123", Arrays.asList("_session-1", "_session-3")), null, null);
String query = SamlTestSupport.redirectQuery("SAMLRequest", xml, saml.idpKeys);
assertEquals(SamlSso.LogoutMessage.IDP_LOGOUT_REQUEST, process(SamlTestSupport.slsGet(query)));
assertTrue(loggedIn.isInvalid());
assertTrue(secondLogin.isInvalid());
assertFalse(otherUser.isInvalid());
}
/**
* A captured signed query with an unsigned message prepended under a percent-encoded name: the
* container decodes the alias to SAMLRequest and serves it first, so it must not borrow the
* signature of the real SAMLRequest.
*/
@Test
public void unsignedMessageUnderEncodedAlias_DoesNotBorrowSignature() throws Exception {
String signed = logoutRequestQuery("user-456", "_session-2", saml.idpKeys);
String unsigned = logoutRequestQuery("user-123", null, null);
String smuggled = "%53AMLRequest" + unsigned.substring("SAMLRequest".length()) + "&" + signed;
assertEquals(SamlSso.LogoutMessage.INVALID, process(SamlTestSupport.slsGet(smuggled)));
assertFalse(loggedIn.isInvalid());
assertFalse(otherUser.isInvalid());
}
@Test
public void repeatedSignedParameter_IsRejected() throws Exception {
String signed = logoutRequestQuery("user-456", "_session-2", saml.idpKeys);
String unsigned = logoutRequestQuery("user-123", null, null);
assertEquals(
SamlSso.LogoutMessage.INVALID, process(SamlTestSupport.slsGet(unsigned + "&" + signed)));
assertFalse(loggedIn.isInvalid());
}
@Test
public void redirectSignatureOnPostRequest_IsNotAccepted() throws Exception {
String signed = logoutRequestQuery("user-456", "_session-2", saml.idpKeys);
String unsignedXml =
SamlTestSupport.toXml(saml.logoutRequest("user-123", (String) null), null, null);
MockHttpServletRequest request = SamlTestSupport.slsPost("SAMLRequest", unsignedXml);
request.setQueryString(signed);
assertEquals(SamlSso.LogoutMessage.INVALID, process(request));
assertFalse(loggedIn.isInvalid());
}
private static String signedRedirect(LogoutRequest request) throws Exception {
return SamlTestSupport.redirectQuery(
"SAMLRequest", SamlTestSupport.toXml(request, null, null), saml.idpKeys);
}
@Test
public void signedLogoutRequest_ForAnotherSp_EndsNothing() throws Exception {
LogoutRequest request = saml.logoutRequest("user-123", "_session-1");
request.setDestination("https://other-sp.test/saml/sls");
assertEquals(
SamlSso.LogoutMessage.INVALID, process(SamlTestSupport.slsGet(signedRedirect(request))));
assertFalse(loggedIn.isInvalid());
}
@Test
public void signedLogoutRequest_WithoutDestination_EndsNothing() throws Exception {
LogoutRequest request = saml.logoutRequest("user-123", "_session-1");
request.setDestination(null);
assertEquals(
SamlSso.LogoutMessage.INVALID, process(SamlTestSupport.slsGet(signedRedirect(request))));
assertFalse(loggedIn.isInvalid());
}
@Test
public void staleLogoutRequest_EndsNothing() throws Exception {
LogoutRequest request = saml.logoutRequest("user-123", "_session-1");
request.setIssueInstant(Instant.now().minus(Duration.ofMinutes(20)));
assertEquals(
SamlSso.LogoutMessage.INVALID, process(SamlTestSupport.slsGet(signedRedirect(request))));
assertFalse(loggedIn.isInvalid());
}
@Test
public void futureDatedLogoutRequest_EndsNothing() throws Exception {
LogoutRequest request = saml.logoutRequest("user-123", "_session-1");
request.setIssueInstant(Instant.now().plus(Duration.ofMinutes(20)));
assertEquals(
SamlSso.LogoutMessage.INVALID, process(SamlTestSupport.slsGet(signedRedirect(request))));
assertFalse(loggedIn.isInvalid());
}
@Test
public void expiredLogoutRequest_EndsNothing() throws Exception {
LogoutRequest request = saml.logoutRequest("user-123", "_session-1");
request.setNotOnOrAfter(Instant.now().minus(Duration.ofMinutes(10)));
assertEquals(
SamlSso.LogoutMessage.INVALID, process(SamlTestSupport.slsGet(signedRedirect(request))));
assertFalse(loggedIn.isInvalid());
}
@Test
public void replayedLogoutRequest_DoesNotEndALaterLogin() throws Exception {
String query = signedRedirect(saml.logoutRequest("user-123", (String) null));
assertEquals(SamlSso.LogoutMessage.IDP_LOGOUT_REQUEST, process(SamlTestSupport.slsGet(query)));
assertTrue(loggedIn.isInvalid());
// The user logs in again
MockHttpSession newLogin = new MockHttpSession();
register(newLogin, "user-123", "_session-new");
assertEquals(SamlSso.LogoutMessage.INVALID, process(SamlTestSupport.slsGet(query)));
assertFalse(newLogin.isInvalid());
newLogin.invalidate();
}
/** XML-DSig ecdsa-sha256 signatures are raw r || s, not the DER that SHA256withECDSA expects */
@Test
public void ecdsaSignedRedirectLogoutResponse_FromEcIdp_IsAccepted() throws Exception {
KeyPair ecKeys = SamlTestSupport.newEcKeyPair();
Properties props = signedLogoutProperties(true);
props.setProperty(
SamlSso.IDP_CERT,
SamlTestSupport.certificatePem(SamlTestSupport.selfSigned(ecKeys, "idp.test")));
SamlTestSupport.install(props);
String query =
SamlTestSupport.redirectQuery(
"SAMLResponse",
logoutResponseXml(),
ecKeys,
"http://www.w3.org/2001/04/xmldsig-more#ecdsa-sha256",
"SHA256withECDSAinP1363Format");
assertEquals(SamlSso.LogoutMessage.LOGOUT_RESPONSE, process(SamlTestSupport.slsGet(query)));
}
@Test
public void nameIdOnlyLogout_DoesNotEndSameValueWithDifferentFormatOrQualifier()
throws Exception {
MockHttpSession emailFormat = new MockHttpSession();
SamlSessionRegistry.register(
emailFormat, SamlSso.nameIdKey("user-123", NameIDType.EMAIL, null, null), "_e");
MockHttpSession otherIdp = new MockHttpSession();
SamlSessionRegistry.register(
otherIdp,
SamlSso.nameIdKey("user-123", NameIDType.PERSISTENT, "https://other-idp.test", null),
"_o");
assertEquals(
SamlSso.LogoutMessage.IDP_LOGOUT_REQUEST,
process(
SamlTestSupport.slsGet(signedRedirect(saml.logoutRequest("user-123", (String) null)))));
assertTrue(loggedIn.isInvalid());
assertFalse(emailFormat.isInvalid());
assertFalse(otherIdp.isInvalid());
emailFormat.invalidate();
otherIdp.invalidate();
}
@Test
public void explicitDefaultQualifiers_MatchOmittedOnes() throws Exception {
LogoutRequest request = saml.logoutRequest("user-123", "_session-1");
request.getNameID().setNameQualifier(SamlTestSupport.IDP_ENTITY_ID);
request.getNameID().setSPNameQualifier(SamlTestSupport.SP_ENTITY_ID);
assertEquals(
SamlSso.LogoutMessage.IDP_LOGOUT_REQUEST,
process(SamlTestSupport.slsGet(signedRedirect(request))));
assertTrue(loggedIn.isInvalid());
}
@Test
public void withoutSlsUrl_IdpLogoutRequestIsRefusedAndEndsNothing() throws Exception {
Properties props = signedLogoutProperties(true);
props.remove(SamlSso.SP_SLS_URL);
SamlTestSupport.install(props);
LogoutRequest request = saml.logoutRequest("user-123", "_session-1");
request.setDestination(SamlTestSupport.ACS_URL);
assertEquals(
SamlSso.LogoutMessage.INVALID, process(SamlTestSupport.slsGet(signedRedirect(request))));
assertFalse(loggedIn.isInvalid());
}
/**
* Unsigned requests that end nothing must not take replay-cache slots; otherwise flooding them
* would make genuine signed logouts be refused as "too many".
*/
@Test
public void floodOfUnsignedNoOpLogouts_DoesNotBlockSignedLogout() throws Exception {
SamlSso.maxRememberedLogoutRequests = 5;
SamlTestSupport.install(signedLogoutProperties(false));
for (int i = 0; i < 10; i++) {
process(SamlTestSupport.slsGet(logoutRequestQuery("user-" + i, null, null)));
}
MockHttpServletRequest signed =
SamlTestSupport.slsGet(logoutRequestQuery("user-123", "_session-1", saml.idpKeys));
assertEquals(SamlSso.LogoutMessage.IDP_LOGOUT_REQUEST, process(signed));
assertTrue(loggedIn.isInvalid());
}
@Test
public void unsignedLogoutNamingATrackedLogin_StillEndsIt() throws Exception {
SamlTestSupport.install(signedLogoutProperties(false));
assertEquals(
SamlSso.LogoutMessage.IDP_LOGOUT_REQUEST,
process(SamlTestSupport.slsGet(logoutRequestQuery("user-123", "_session-1", null))));
assertTrue(loggedIn.isInvalid());
assertFalse(otherUser.isInvalid());
}
@Test
public void endedSession_IsRemovedFromRegistry() {
int before = SamlSessionRegistry.size();
loggedIn.invalidate();
assertEquals(before - 1, SamlSessionRegistry.size());
}
}