41
41
import org .springframework .security .web .authentication .SimpleUrlAuthenticationSuccessHandler ;
42
42
import org .springframework .security .web .authentication .ott .GeneratedOneTimeTokenHandler ;
43
43
import org .springframework .security .web .authentication .ott .RedirectGeneratedOneTimeTokenHandler ;
44
+ import org .springframework .security .web .csrf .CsrfToken ;
45
+ import org .springframework .security .web .csrf .DefaultCsrfToken ;
46
+ import org .springframework .security .web .csrf .HttpSessionCsrfTokenRepository ;
44
47
import org .springframework .test .web .servlet .MockMvc ;
45
48
49
+ import static org .assertj .core .api .Assertions .assertThat ;
46
50
import static org .assertj .core .api .Assertions .assertThatException ;
47
51
import static org .springframework .security .test .web .servlet .request .SecurityMockMvcRequestPostProcessors .csrf ;
48
52
import static org .springframework .security .test .web .servlet .response .SecurityMockMvcResultMatchers .authenticated ;
49
53
import static org .springframework .security .test .web .servlet .response .SecurityMockMvcResultMatchers .unauthenticated ;
54
+ import static org .springframework .test .web .servlet .request .MockMvcRequestBuilders .get ;
50
55
import static org .springframework .test .web .servlet .request .MockMvcRequestBuilders .post ;
51
56
import static org .springframework .test .web .servlet .result .MockMvcResultMatchers .redirectedUrl ;
52
57
import static org .springframework .test .web .servlet .result .MockMvcResultMatchers .status ;
@@ -59,6 +64,143 @@ public class OneTimeTokenLoginConfigurerTests {
59
64
@ Autowired (required = false )
60
65
MockMvc mvc ;
61
66
67
+ public static final String EXPECTED_HTML_HEAD = """
68
+ <!DOCTYPE html>
69
+ <html lang="en">
70
+ <head>
71
+ <meta charset="utf-8">
72
+ <meta name="viewport" content="width=device-width, initial-scale=1, shrink-to-fit=no">
73
+ <meta name="description" content="">
74
+ <meta name="author" content="">
75
+ <title>Please sign in</title>
76
+ <style>
77
+ /* General layout */
78
+ body {
79
+ font-family: system-ui, "Segoe UI", Roboto, "Helvetica Neue", Arial, sans-serif;
80
+ background-color: #eee;
81
+ padding: 40px 0;
82
+ margin: 0;
83
+ line-height: 1.5;
84
+ }
85
+ \s \s \s \s
86
+ h2 {
87
+ margin-top: 0;
88
+ margin-bottom: 0.5rem;
89
+ font-size: 2rem;
90
+ font-weight: 500;
91
+ line-height: 2rem;
92
+ }
93
+ \s \s \s \s
94
+ .content {
95
+ margin-right: auto;
96
+ margin-left: auto;
97
+ padding-right: 15px;
98
+ padding-left: 15px;
99
+ width: 100%;
100
+ box-sizing: border-box;
101
+ }
102
+ \s \s \s \s
103
+ @media (min-width: 800px) {
104
+ .content {
105
+ max-width: 760px;
106
+ }
107
+ }
108
+ \s \s \s \s
109
+ /* Components */
110
+ a,
111
+ a:visited {
112
+ text-decoration: none;
113
+ color: #06f;
114
+ }
115
+ \s \s \s \s
116
+ a:hover {
117
+ text-decoration: underline;
118
+ color: #003c97;
119
+ }
120
+ \s \s \s \s
121
+ input[type="text"],
122
+ input[type="password"] {
123
+ height: auto;
124
+ width: 100%;
125
+ font-size: 1rem;
126
+ padding: 0.5rem;
127
+ box-sizing: border-box;
128
+ }
129
+ \s \s \s \s
130
+ button {
131
+ padding: 0.5rem 1rem;
132
+ font-size: 1.25rem;
133
+ line-height: 1.5;
134
+ border: none;
135
+ border-radius: 0.1rem;
136
+ width: 100%;
137
+ }
138
+ \s \s \s \s
139
+ button.primary {
140
+ color: #fff;
141
+ background-color: #06f;
142
+ }
143
+ \s \s \s \s
144
+ .alert {
145
+ padding: 0.75rem 1rem;
146
+ margin-bottom: 1rem;
147
+ line-height: 1.5;
148
+ border-radius: 0.1rem;
149
+ width: 100%;
150
+ box-sizing: border-box;
151
+ border-width: 1px;
152
+ border-style: solid;
153
+ }
154
+ \s \s \s \s
155
+ .alert.alert-danger {
156
+ color: #6b1922;
157
+ background-color: #f7d5d7;
158
+ border-color: #eab6bb;
159
+ }
160
+ \s \s \s \s
161
+ .alert.alert-success {
162
+ color: #145222;
163
+ background-color: #d1f0d9;
164
+ border-color: #c2ebcb;
165
+ }
166
+ \s \s \s \s
167
+ .screenreader {
168
+ position: absolute;
169
+ clip: rect(0 0 0 0);
170
+ height: 1px;
171
+ width: 1px;
172
+ padding: 0;
173
+ border: 0;
174
+ overflow: hidden;
175
+ }
176
+ \s \s \s \s
177
+ table {
178
+ width: 100%;
179
+ max-width: 100%;
180
+ margin-bottom: 2rem;
181
+ }
182
+ \s \s \s \s
183
+ .table-striped tr:nth-of-type(2n + 1) {
184
+ background-color: #e1e1e1;
185
+ }
186
+ \s \s \s \s
187
+ td {
188
+ padding: 0.75rem;
189
+ vertical-align: top;
190
+ }
191
+ \s \s \s \s
192
+ /* Login / logout layouts */
193
+ .login-form,
194
+ .logout-form {
195
+ max-width: 340px;
196
+ padding: 0 15px 15px 15px;
197
+ margin: 0 auto 2rem auto;
198
+ box-sizing: border-box;
199
+ }
200
+ </style>
201
+ </head>
202
+ """ ;
203
+
62
204
@ Test
63
205
void oneTimeTokenWhenCorrectTokenThenCanAuthenticate () throws Exception {
64
206
this .spring .register (OneTimeTokenDefaultConfig .class ).autowire ();
@@ -110,6 +252,54 @@ void oneTimeTokenWhenWrongTokenThenAuthenticationFail() throws Exception {
110
252
.andExpectAll (status ().isFound (), redirectedUrl ("/login?error" ), unauthenticated ());
111
253
}
112
254
255
+ @ Test
256
+ void oneTimeTokenWhenFormLoginConfiguredThenRendersRequestTokenForm () throws Exception {
257
+ this .spring .register (OneTimeTokenFormLoginConfig .class ).autowire ();
258
+ CsrfToken csrfToken = new DefaultCsrfToken ("X-CSRF-TOKEN" , "_csrf" , "BaseSpringSpec_CSRFTOKEN" );
259
+ String csrfAttributeName = HttpSessionCsrfTokenRepository .class .getName ().concat (".CSRF_TOKEN" );
260
+ //@formatter:off
261
+ this .mvc .perform (get ("/login" ).sessionAttr (csrfAttributeName , csrfToken ))
262
+ .andExpect ((result ) -> {
263
+ CsrfToken token = (CsrfToken ) result .getRequest ().getAttribute (CsrfToken .class .getName ());
264
+ assertThat (result .getResponse ().getContentAsString ()).isEqualTo (
265
+ EXPECTED_HTML_HEAD +
266
+ """
267
+ <body>
268
+ <div class="content">
269
+ <form class="login-form" method="post" action="/login">
270
+ <h2>Please sign in</h2>
271
+ \s
272
+ <p>
273
+ <label for="username" class="screenreader">Username</label>
274
+ <input type="text" id="username" name="username" placeholder="Username" required autofocus>
275
+ </p>
276
+ <p>
277
+ <label for="password" class="screenreader">Password</label>
278
+ <input type="password" id="password" name="password" placeholder="Password" required>
279
+ </p>
280
+
281
+ <input name="_csrf" type="hidden" value="%s" />
282
+ <button type="submit" class="primary">Sign in</button>
283
+ </form>
284
+ <form id="ott-form" class="login-form" method="post" action="/ott/generate">
285
+ <h2>Request a One-Time Token</h2>
286
+ \s
287
+ <p>
288
+ <label for="ott-username" class="screenreader">Username</label>
289
+ <input type="text" id="ott-username" name="username" placeholder="Username" required>
290
+ </p>
291
+ <input name="_csrf" type="hidden" value="%s" />
292
+ <button class="primary" type="submit" form="ott-form">Send Token</button>
293
+ </form>
294
+
295
+
296
+ </div>
297
+ </body>
298
+ </html>""" .formatted (token .getToken (), token .getToken ()));
299
+ });
300
+ //@formatter:on
301
+ }
302
+
113
303
@ Test
114
304
void oneTimeTokenWhenNoGeneratedOneTimeTokenHandlerThenException () {
115
305
assertThatException ()
@@ -167,6 +357,28 @@ SecurityFilterChain securityFilterChain(HttpSecurity http) throws Exception {
167
357
168
358
}
169
359
360
+ @ Configuration (proxyBeanMethods = false )
361
+ @ EnableWebSecurity
362
+ @ Import (UserDetailsServiceConfig .class )
363
+ static class OneTimeTokenFormLoginConfig {
364
+
365
+ @ Bean
366
+ SecurityFilterChain securityFilterChain (HttpSecurity http ) throws Exception {
367
+ // @formatter:off
368
+ http
369
+ .authorizeHttpRequests ((authz ) -> authz
370
+ .anyRequest ().authenticated ()
371
+ )
372
+ .formLogin (Customizer .withDefaults ())
373
+ .oneTimeTokenLogin ((ott ) -> ott
374
+ .generatedOneTimeTokenHandler (new TestGeneratedOneTimeTokenHandler ())
375
+ );
376
+ // @formatter:on
377
+ return http .build ();
378
+ }
379
+
380
+ }
381
+
170
382
@ Configuration (proxyBeanMethods = false )
171
383
@ EnableWebSecurity
172
384
@ Import (UserDetailsServiceConfig .class )
0 commit comments