Skip to content

Commit d5579da

Browse files
author
Dave Syer
committed
Add new chapter on custom error page
Shows how to do some custom authentication and also give users feedback if they can't log in.
1 parent 12ec11a commit d5579da

File tree

8 files changed

+506
-0
lines changed

8 files changed

+506
-0
lines changed

README.adoc

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -32,6 +32,7 @@ include::logout/README.adoc[leveloffset=+1]
3232
include::manual/README.adoc[leveloffset=+1]
3333
include::github/README.adoc[leveloffset=+1]
3434
include::auth-server/README.adoc[leveloffset=+1]
35+
include::custom-error/README.adoc[leveloffset=+1]
3536

3637
== Conclusion
3738

custom-error/README.adoc

Lines changed: 157 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,157 @@
1+
[[_custom_error]]
2+
= Adding an Error Page for Unauthenticated Users
3+
4+
In this section we modify the <<_social_login_logout,logout>> app we
5+
built earlier, switching to Github authentication, and also giving
6+
some feedback to users that cannot authenticate. At the same time we
7+
take the opportunity to extend the authentication logic to include a
8+
rule that only allows users if they belong to a specific Github
9+
organization. The "organization" is a Github domain-specific concept,
10+
but similar rules could be devised for other providers, e.g. with
11+
Google you might want to only authenticate users from a specific
12+
domain.
13+
14+
== Switching to Github
15+
16+
The <<_social_login_logout,logout>> sample use Facebook as an OAuth2
17+
provider. We can easily switch to Github by changing the local
18+
configuration:
19+
20+
.application.yml
21+
[source,yaml]
22+
----
23+
security:
24+
oauth2:
25+
client:
26+
clientId: bd1c0a783ccdd1c9b9e4
27+
clientSecret: 1a9030fbca47a5b2c28e92f19050bb77824b5ad1
28+
accessTokenUri: https://github.com/login/oauth/access_token
29+
userAuthorizationUri: https://github.com/login/oauth/authorize
30+
clientAuthenticationScheme: form
31+
resource:
32+
userInfoUri: https://api.github.com/user
33+
----
34+
35+
== Detecting an Authentication Failure in the Client
36+
37+
On the client we need to be able to provide some feedback for a user
38+
that could not authenticate. To facilitate this we add a div with an
39+
informative message:
40+
41+
.index.html
42+
----
43+
<div class="container text-danger" ng-show="home.error">
44+
There was an error (bad credentials).
45+
</div>
46+
----
47+
48+
This text will only be shown when the "error" flag is set in the controller,
49+
so we need some code to do that:
50+
51+
.index.html
52+
----
53+
angular
54+
.module("app", [])
55+
.controller("home", function($http, $location) {
56+
var self = this;
57+
self.logout = function() {
58+
if ($location.absUrl().indexOf("error=true") >= 0) {
59+
self.authenticated = false;
60+
self.error = true;
61+
}
62+
...
63+
};
64+
});
65+
----
66+
67+
The "home" controller checks the browser location when it loads
68+
and if it finds a URL with "error=true" in it, the flag is set.
69+
70+
== Adding an Error Page
71+
72+
To support the flag setting in the client we need to be able to
73+
capture an authentication error and redirect to the home page
74+
with that flag set in query parameters. Hence we need an
75+
endpoint, in a regular `@Controller` like this:
76+
77+
.SocialApplication.java
78+
[source,java]
79+
----
80+
@RequestMapping("/unauthenticated")
81+
public String unauthenticated() {
82+
return "redirect:/?error=true";
83+
}
84+
----
85+
86+
In the sample app we put this in the main application class, which is
87+
now a `@Controller` (not a `@RestController`) so it can handle the
88+
redirect. The last thing we need is a mapping from an unauthenticated
89+
response (HTTP 401, a.k.a. UNAUTHORIZED) to the "/unauthenticated"
90+
endpoint we just added:
91+
92+
.ServletCustomizer.java
93+
[source,java]
94+
----
95+
@Configuration
96+
public class ServletCustomizer {
97+
@Bean
98+
public EmbeddedServletContainerCustomizer customizer() {
99+
return container -> {
100+
container.addErrorPages(
101+
new ErrorPage(HttpStatus.UNAUTHORIZED, "/unauthenticated"));
102+
};
103+
}
104+
}
105+
----
106+
107+
(In the sample, this is added as a nested class inside the main
108+
application, just for conciseness.)
109+
110+
== Generating a 401 in the Server
111+
112+
A 401 response will already be coming from Spring Security if the user
113+
cannot or does not want to login with Github. What we set out to do
114+
was extend that rule to reject users that are not in the right
115+
origanization. It is easy to use the Github API to find out more about
116+
the user, including his organizations, so we just need to plug that
117+
into the right part of the authentication process. Fortunately, for
118+
such a simple use case, Spring Boot has provided an easy extension
119+
point: if we declare a `@Bean` of type `AuthoritiesExtractor` it will
120+
be used to construct the authorities (typically "roles") of an
121+
authenticated user. We can use that hook to assert the the user is in
122+
the correct orignization, and throw an exception if not:
123+
124+
.SocialApplication.java
125+
[source,java]
126+
----
127+
@Bean
128+
public AuthoritiesExtractor authoritiesExtractor(OAuth2RestOperations template) {
129+
return map -> {
130+
String url = (String) map.get("organizations_url");
131+
@SuppressWarnings("unchecked")
132+
List<Map<String, Object>> orgs = template.getForObject(url, List.class);
133+
for (Map<String, Object> org : orgs) {
134+
if ("spring-projects".equals(org.get("login"))) {
135+
return AuthorityUtils
136+
.commaSeparatedStringToAuthorityList("ROLE_USER");
137+
}
138+
}
139+
throw new BadCredentialsException("Not in Spring Projects origanization");
140+
};
141+
}
142+
----
143+
144+
Note that we have autowired a `OAuth2RestOperations` into this method,
145+
so we can use that to access the Github API on behalf of the
146+
authenticated user. We do that, and loop over the organizations,
147+
looking for one that matches "spring-projects" (this is the
148+
organization that is used to store Spring open source projects). You
149+
can substitute your own value there if you want to be able to
150+
authenticate successfully and you are not in the Spring Engineering
151+
team. If there is no match, we throw `BadCredentialsException` and
152+
this is picked up by Spring Security and turned in to a 401 response.
153+
154+
TIP: Obviously the code above can be generalized to other
155+
authentication rules, some applicable to Github and some to other
156+
OAuth2 providers. All you need is the `OAuth2RestOperations` and some
157+
knowledge of the provider's API.

custom-error/pom.xml

Lines changed: 79 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,79 @@
1+
<?xml version="1.0" encoding="UTF-8"?>
2+
<project xmlns="http://maven.apache.org/POM/4.0.0" xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
3+
xsi:schemaLocation="http://maven.apache.org/POM/4.0.0 http://maven.apache.org/xsd/maven-4.0.0.xsd">
4+
<modelVersion>4.0.0</modelVersion>
5+
6+
<groupId>com.example</groupId>
7+
<artifactId>social-logout</artifactId>
8+
<version>0.0.1-SNAPSHOT</version>
9+
<packaging>jar</packaging>
10+
11+
<name>social-logout</name>
12+
<description>Demo project for Spring Boot</description>
13+
14+
<parent>
15+
<groupId>org.springframework.boot</groupId>
16+
<artifactId>spring-boot-starter-parent</artifactId>
17+
<version>1.3.2.RELEASE</version>
18+
<relativePath /> <!-- lookup parent from repository -->
19+
</parent>
20+
21+
<properties>
22+
<project.build.sourceEncoding>UTF-8</project.build.sourceEncoding>
23+
<java.version>1.8</java.version>
24+
</properties>
25+
26+
<dependencies>
27+
<dependency>
28+
<groupId>org.springframework.boot</groupId>
29+
<artifactId>spring-boot-starter-actuator</artifactId>
30+
</dependency>
31+
<dependency>
32+
<groupId>org.springframework.boot</groupId>
33+
<artifactId>spring-boot-starter-security</artifactId>
34+
</dependency>
35+
<dependency>
36+
<groupId>org.springframework.boot</groupId>
37+
<artifactId>spring-boot-starter-web</artifactId>
38+
</dependency>
39+
<dependency>
40+
<groupId>org.springframework.security.oauth</groupId>
41+
<artifactId>spring-security-oauth2</artifactId>
42+
</dependency>
43+
<dependency>
44+
<groupId>org.webjars</groupId>
45+
<artifactId>angularjs</artifactId>
46+
<version>1.4.3</version>
47+
</dependency>
48+
<dependency>
49+
<groupId>org.webjars</groupId>
50+
<artifactId>jquery</artifactId>
51+
<version>2.1.1</version>
52+
</dependency>
53+
<dependency>
54+
<groupId>org.webjars</groupId>
55+
<artifactId>bootstrap</artifactId>
56+
<version>3.2.0</version>
57+
</dependency>
58+
<dependency>
59+
<groupId>org.webjars</groupId>
60+
<artifactId>webjars-locator</artifactId>
61+
</dependency>
62+
63+
<dependency>
64+
<groupId>org.springframework.boot</groupId>
65+
<artifactId>spring-boot-starter-test</artifactId>
66+
<scope>test</scope>
67+
</dependency>
68+
</dependencies>
69+
70+
<build>
71+
<plugins>
72+
<plugin>
73+
<groupId>org.springframework.boot</groupId>
74+
<artifactId>spring-boot-maven-plugin</artifactId>
75+
</plugin>
76+
</plugins>
77+
</build>
78+
79+
</project>
Lines changed: 142 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,142 @@
1+
/*
2+
* Copyright 2012-2015 the original author or authors.
3+
*
4+
* Licensed under the Apache License, Version 2.0 (the "License");
5+
* you may not use this file except in compliance with the License.
6+
* You may obtain a copy of the License at
7+
*
8+
* http://www.apache.org/licenses/LICENSE-2.0
9+
*
10+
* Unless required by applicable law or agreed to in writing, software
11+
* distributed under the License is distributed on an "AS IS" BASIS,
12+
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
13+
* See the License for the specific language governing permissions and
14+
* limitations under the License.
15+
*/
16+
package com.example;
17+
18+
import java.io.IOException;
19+
import java.security.Principal;
20+
import java.util.List;
21+
import java.util.Map;
22+
23+
import javax.servlet.Filter;
24+
import javax.servlet.FilterChain;
25+
import javax.servlet.ServletException;
26+
import javax.servlet.http.Cookie;
27+
import javax.servlet.http.HttpServletRequest;
28+
import javax.servlet.http.HttpServletResponse;
29+
30+
import org.springframework.boot.SpringApplication;
31+
import org.springframework.boot.autoconfigure.SpringBootApplication;
32+
import org.springframework.boot.autoconfigure.security.oauth2.client.EnableOAuth2Sso;
33+
import org.springframework.boot.autoconfigure.security.oauth2.resource.AuthoritiesExtractor;
34+
import org.springframework.boot.context.embedded.EmbeddedServletContainerCustomizer;
35+
import org.springframework.boot.context.embedded.ErrorPage;
36+
import org.springframework.context.annotation.Bean;
37+
import org.springframework.context.annotation.Configuration;
38+
import org.springframework.http.HttpStatus;
39+
import org.springframework.security.authentication.BadCredentialsException;
40+
import org.springframework.security.config.annotation.web.builders.HttpSecurity;
41+
import org.springframework.security.config.annotation.web.configuration.WebSecurityConfigurerAdapter;
42+
import org.springframework.security.core.authority.AuthorityUtils;
43+
import org.springframework.security.oauth2.client.OAuth2RestOperations;
44+
import org.springframework.security.web.csrf.CsrfFilter;
45+
import org.springframework.security.web.csrf.CsrfToken;
46+
import org.springframework.security.web.csrf.CsrfTokenRepository;
47+
import org.springframework.security.web.csrf.HttpSessionCsrfTokenRepository;
48+
import org.springframework.stereotype.Controller;
49+
import org.springframework.web.bind.annotation.RequestMapping;
50+
import org.springframework.web.bind.annotation.ResponseBody;
51+
import org.springframework.web.filter.OncePerRequestFilter;
52+
import org.springframework.web.util.WebUtils;
53+
54+
@SpringBootApplication
55+
@EnableOAuth2Sso
56+
@Controller
57+
public class SocialApplication extends WebSecurityConfigurerAdapter {
58+
59+
@Bean
60+
public AuthoritiesExtractor authoritiesExtractor(OAuth2RestOperations template) {
61+
return map -> {
62+
String url = (String) map.get("organizations_url");
63+
@SuppressWarnings("unchecked")
64+
List<Map<String, Object>> orgs = template.getForObject(url, List.class);
65+
for (Map<String, Object> org : orgs) {
66+
if ("spring-projects".equals(org.get("login"))) {
67+
return AuthorityUtils
68+
.commaSeparatedStringToAuthorityList("ROLE_USER");
69+
}
70+
}
71+
throw new BadCredentialsException("Not in Spring Team");
72+
};
73+
}
74+
75+
@RequestMapping("/user")
76+
@ResponseBody
77+
public Principal user(Principal principal) {
78+
return principal;
79+
}
80+
81+
@RequestMapping("/unauthenticated")
82+
public String unauthenticated() {
83+
return "redirect:/?error=true";
84+
}
85+
86+
@Override
87+
protected void configure(HttpSecurity http) throws Exception {
88+
// @formatter:off
89+
http.antMatcher("/**").authorizeRequests()
90+
.antMatchers("/", "/login**", "/webjars/**").permitAll()
91+
.anyRequest().authenticated().and()
92+
.logout().logoutSuccessUrl("/").permitAll().and()
93+
.csrf().csrfTokenRepository(csrfTokenRepository()).and()
94+
.addFilterAfter(csrfHeaderFilter(), CsrfFilter.class);
95+
// @formatter:on
96+
}
97+
98+
@Configuration
99+
protected static class ServletCustomizer {
100+
@Bean
101+
public EmbeddedServletContainerCustomizer customizer() {
102+
return container -> {
103+
container.addErrorPages(
104+
new ErrorPage(HttpStatus.UNAUTHORIZED, "/unauthenticated"));
105+
};
106+
}
107+
}
108+
109+
public static void main(String[] args) {
110+
SpringApplication.run(SocialApplication.class, args);
111+
}
112+
113+
private Filter csrfHeaderFilter() {
114+
return new OncePerRequestFilter() {
115+
@Override
116+
protected void doFilterInternal(HttpServletRequest request,
117+
HttpServletResponse response, FilterChain filterChain)
118+
throws ServletException, IOException {
119+
CsrfToken csrf = (CsrfToken) request
120+
.getAttribute(CsrfToken.class.getName());
121+
if (csrf != null) {
122+
Cookie cookie = WebUtils.getCookie(request, "XSRF-TOKEN");
123+
String token = csrf.getToken();
124+
if (cookie == null
125+
|| token != null && !token.equals(cookie.getValue())) {
126+
cookie = new Cookie("XSRF-TOKEN", token);
127+
cookie.setPath("/");
128+
response.addCookie(cookie);
129+
}
130+
}
131+
filterChain.doFilter(request, response);
132+
}
133+
};
134+
}
135+
136+
private CsrfTokenRepository csrfTokenRepository() {
137+
HttpSessionCsrfTokenRepository repository = new HttpSessionCsrfTokenRepository();
138+
repository.setHeaderName("X-XSRF-TOKEN");
139+
return repository;
140+
}
141+
142+
}

0 commit comments

Comments
 (0)