In the previous post, we explored the concept of OAuth 2
and the considerations for its implementation. In this post, we will discuss how to extend OAuth 2 to configure a Single Sign-On (SSO) environment, and how to build an SSO environment using Spring Boot
and Spring Security OAuth
.
1. Extending OAuth 2 for SSO Environment
To set up SSO using OAuth
, we used the authorization code grant type, which is one of the authorization methods provided by OAuth. The SSO server was configured with the two standard OAuth endpoints and two custom endpoints. We will review the login, SSO handling, and logout scenarios in the SSO environment.
1.1. Login Scenario
The login scenario in the configured SSO environment is as follows. Each system redirects the request from an unauthenticated user to the SSO server.
- If there is no valid authentication history, the SSO server responds with the login page.
- The SSO server, upon receiving a login request with valid credentials, redirects the request to the system (including the authorization code).
- The system, upon receiving the request with the authorization code, obtains the access token and user information from the SSO server and processes the login for the user, then responds with the result of the original request.
1.2. SSO Processing Scenario
The SSO processing scenario in an SSO-configured environment is as follows. When a user logged into one system attempts to access a page that requires authentication on another system, the system redirects the request to the SSO server.
- For a request with valid authentication history, the SSO server redirects the request to the system (including the authorization code).
- The system, upon receiving the request with the authorization code, obtains the access token and user information from the SSO server and processes the login for the user, then responds with the result of the original request.
1.3. Logout Scenario
When a logged-in user logs out of the SSO environment, the process is as follows:
- The logged-in user requests a logout from one system, which redirects the request to the logout page on the SSO server.
- Upon receiving the logout request, the SSO server sends logout requests to all systems where the user is logged in.
2. Building the SSO Environment
To set up the SSO environment described earlier, we used Spring Boot and Spring Security OAuth2. First, we will explain how to set up the SSO server, followed by how to build the client systems.
2.1. SSO Server Setup
2.1.1. Project Structure
The overall structure of the SSO server project is shown below. For simplicity, test-related configurations have been omitted.
After creating the project with Maven, we edit the pom.xml
file to include Spring Boot and other required libraries.
<project ......>
......
<parent>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-parent</artifactId>
<version>1.5.2.RELEASE</version>
</parent>
<dependencies>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-security</artifactId>
</dependency>
<dependency>
<groupId>org.springframework.security.oauth</groupId>
<artifactId>spring-security-oauth2</artifactId>
</dependency>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-jdbc</artifactId>
</dependency>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-data-jpa</artifactId>
</dependency>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-web</artifactId>
</dependency>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-thymeleaf</artifactId>
</dependency>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-devtools</artifactId>
<optional>true</optional>
</dependency>
<dependency>
<groupId>com.h2database</groupId>
<artifactId>h2</artifactId>
<scope>runtime</scope>
</dependency>
<dependency>
<groupId>org.apache.httpcomponents</groupId>
<artifactId>httpclient</artifactId>
</dependency>
<dependency>
<groupId>org.webjars</groupId>
<artifactId>jquery</artifactId>
<version>3.2.0</version>
</dependency>
<dependency>
<groupId>org.webjars</groupId>
<artifactId>bootstrap</artifactId>
<version>3.3.7</version>
</dependency>
</dependencies>
......
</project>
2.1.2. Spring Boot Application Configuration File
Under the src/main/resources
directory, create the application configuration file application.yml
as follows:
server:
port: 8080
session:
cookie:
name: APPSESSIONID
spring.h2.console:
enabled: true
path: /h2-console
spring:
jpa:
generate-ddl: false
hibernate:
ddl-auto: none
logging.level:
root: warn
org.springframework:
web: warn
security: info
boot: info
org.hibernate:
SQL: warn
com.nextree: debug
This configuration sets the port to 8080 and defines the session cookie as APPSESSIONID
.
The H2 database console is enabled at /h2-console
, and JPA is used for database access, with DDL generation disabled.
2.1.3. Spring Boot Application Class
The main class with the @SpringBootApplication
annotation starts the SSO server when run.
@SpringBootApplication
public class SsoServerApplication {
public static void main(String[] args) {
SpringApplication.run(SsoServerApplication.class, args);
}
}
2.1.4. Web Security Configuration Class
The class that enables Spring Security settings and defines login, authorization, and logout behavior.
@Configuration
@EnableWebSecurity
@Order(SecurityProperties.ACCESS_OVERRIDE_ORDER)
public class WebSecurityConfig extends WebSecurityConfigurerAdapter {
@Override
protected void configure(HttpSecurity http) throws Exception {
http
.authorizeRequests()
.antMatchers("/home", "/webjars/**", "/css/**", "/userInfo").permitAll()
.anyRequest().authenticated()
.and()
.formLogin()
.loginProcessingUrl("/login")
.loginPage("/loginForm")
.permitAll()
.and()
.csrf()
.requireCsrfProtectionMatcher(new AntPathRequestMatcher("/user*"))
.disable()
.logout()
.permitAll();
}
@Autowired
public void configureGlobal(AuthenticationManagerBuilder auth) throws Exception {
auth
.inMemoryAuthentication()
.withUser("tsong").password("aaa").roles("USER").and()
.withUser("jmpark").password("aaa").roles("USER").and()
.withUser("jkkang").password("aaa").roles("USER").and()
.withUser("test").password("aaa").roles("USER");
}
}
This configures Spring Security, allowing unrestricted access to certain URLs and requiring authentication for others.
2.1.5. Web MVC Configuration Class
This class configures view controllers for pages like the homepage and login form.
@Configuration
public class WebMvcConfig extends WebMvcConfigurerAdapter {
@Override
public void addViewControllers(ViewControllerRegistry registry) {
registry.addViewController("/home").setViewName("home");
registry.addViewController("/loginForm").setViewName("loginForm");
}
}
2.1.6. Authorization Server Configuration Class
By using @EnableAuthorizationServer
, we enable OAuth authorization functionality in Spring Boot.
@Configuration
@EnableAuthorizationServer
public class AuthorizationServerConfiguration extends AuthorizationServerConfigurerAdapter {
@Autowired
private AuthorizationCodeServices authorizationCodeServices;
@Autowired
private ApprovalStore approvalStore;
@Autowired
private TokenStore tokenStore;
@Override
public void configure(AuthorizationServerEndpointsConfigurer endpoints) throws Exception {
endpoints
.tokenStore(tokenStore)
.authorizationCodeServices(authorizationCodeServices)
.approvalStore(approvalStore);
}
@Bean
public AuthorizationCodeServices jdbcAuthorizationCodeServices(DataSource dataSource) {
return new JdbcAuthorizationCodeServices(dataSource);
}
@Bean
public ApprovalStore jdbcApprovalStore(DataSource dataSource) {
return new JdbcApprovalStore(dataSource);
}
@Bean
@Primary
public ClientDetailsService jdbcClientDetailsService(DataSource dataSource) {
return new JdbcClientDetailsService(dataSource);
}
@Bean
public TokenStore jdbcTokenStore(DataSource dataSource) {
return new JdbcTokenStore(dataSource);
}
}
To store the SSO authentication target clients, authorization codes, and issued token information in the database, I configured the JdbcAuthorizationCodeServices
, JdbcApprovalStore
, JdbcClientDetailsService
, and JdbcTokenStore
provided by Spring Security OAuth2 as beans in the authorization server configuration class.
2.1.7. Database Schema Creation and Initial Data Script
To create the database schema used by the database-related beans registered in the authorization server class, you can write a schema.sql
file containing the DDL statements in the src/main/resources
directory.
Additionally, if initial data needs to be loaded, you can create a data.sql
file containing DML statements in the same directory. These scripts will be executed when the Spring Boot application starts, creating the schema and loading the initial data. Below are the contents of the schema.sql
and data.sql
files.
create table oauth_client_details (
client_id VARCHAR(256) PRIMARY KEY,
resource_ids VARCHAR(256),
client_secret VARCHAR(256),
scope VARCHAR(256),
authorized_grant_types VARCHAR(256),
web_server_redirect_uri VARCHAR(256),
logout_uri VARCHAR(256),
base_uri VARCHAR(256),
authorities VARCHAR(256),
access_token_validity INTEGER,
refresh_token_validity INTEGER,
additional_information VARCHAR(4096),
autoapprove VARCHAR(256)
);
create table oauth_access_token (
token_id VARCHAR(256),
token LONGVARBINARY,
authentication_id VARCHAR(256) PRIMARY KEY,
user_name VARCHAR(256),
client_id VARCHAR(256),
authentication LONGVARBINARY,
refresh_token VARCHAR(256)
);
create table oauth_refresh_token (
token_id VARCHAR(256),
token LONGVARBINARY,
authentication LONGVARBINARY
);
create table oauth_code (
code VARCHAR(256), authentication LONGVARBINARY
);
create table oauth_approvals (
userId VARCHAR(256),
clientId VARCHAR(256),
scope VARCHAR(256),
status VARCHAR(10),
expiresAt TIMESTAMP,
lastModifiedAt TIMESTAMP
);
insert into oauth_client_details (client_id, client_secret,
resource_ids, scope, authorized_grant_types,
web_server_redirect_uri, authorities, access_token_validity,
refresh_token_validity, additional_information,
autoapprove, logout_uri, base_uri)
values ('System1_id', 'System1_secret',
null, 'read', 'authorization_code',
'http://localhost:18010/oauthCallback', 'ROLE_YOUR_CLIENT', 36000,
2592000, null,
'true', 'http://localhost:18010/logout', 'http://localhost:18010/me');
insert into oauth_client_details (client_id, client_secret,
resource_ids, scope, authorized_grant_types,
web_server_redirect_uri, authorities, access_token_validity,
refresh_token_validity, additional_information,
autoapprove, logout_uri, base_uri)
values ('System2_id', 'System2_secret',
null, 'read', 'authorization_code',
'http://localhost:18020/oauthCallback', 'ROLE_YOUR_CLIENT', 36000,
2592000, null,
'true', 'http://localhost:18020/logout', 'http://localhost:18020/me');
insert into oauth_client_details (client_id, client_secret,
resource_ids, scope, authorized_grant_types,
web_server_redirect_uri, authorities, access_token_validity,
refresh_token_validity, additional_information,
autoapprove, logout_uri, base_uri)
values ('System3_id', 'System3_secret',
null, 'read', 'authorization_code',
'http://localhost:18030/oauthCallback', 'ROLE_YOUR_CLIENT', 36000,
2592000, null,
'true', 'http://localhost:18030/logout', 'http://localhost:18030/me');
insert into oauth_client_details (client_id, client_secret,
resource_ids, scope, authorized_grant_types,
web_server_redirect_uri, authorities, access_token_validity,
refresh_token_validity, additional_information,
autoapprove, logout_uri, base_uri)
values ('System4_id', 'System4_secret',
null, 'read', 'authorization_code',
'http://localhost:18040/oauthCallback', 'ROLE_YOUR_CLIENT', 36000,
2592000, null,
'true', 'http://localhost:18040/logout', 'http://localhost:18040/me');
2.1.8. Entity Classes
In the next step, we define JPA entities to store and retrieve OAuth-related data. These entities map to the database tables you created earlier.
@Entity
@Table(name="oauth_client_details")
public class Client {
@Id
@Column(name="client_id")
private String clientId;
@Column(name="web_server_redirect_uri")
private String redirectUri;
@Column(name="logout_uri")
private String logoutUri;
@Column(name="base_uri")
private String baseUri;
// getter/setter
}
@Entity
@Table(name="oauth_access_token")
public class AccessToken {
@Id
private String tokenId;
private String token;
@Column(name="user_name")
private String userName;
@Column(name="authentication_id")
private String authenticationId;
@Column(name="client_id")
private String clientId;
private String authentication;
// getter /setter
}
2.1.9. JPA Repositories
Now, let's define the repositories using Spring Data JPA to handle the entities.
public interface ClientRepository extends CrudRepository<Client, String> {
// Custom queries if needed
}
public interface AccessTokenRepository extends CrudRepository<AccessToken, String> {
AccessToken findByTokenIdAndClientId(String tokenId, String clientId);
int deleteByUserName(String userName);
List<AccessToken> findByUserName(String userName);
}
2.1.10. Service Interface and Implementation
We define a service interface SsoService
for managing access tokens and user logout.
public interface SsoService {
AccessToken getAccessToken(String token, String clientId);
String logoutAllClients(String clientId, String userName);
}
SsoServiceImpl
Implementation:
@Service("ssoService")
public class SsoServiceImpl implements SsoService {
@Autowired
private AccessTokenRepository accessTokenRepository;
@Autowired
private ClientRepository clientRepository;
@Override
public AccessToken getAccessToken(String token, String clientId) {
String tokenId = extractTokenId(token);
return accessTokenRepository.findByTokenIdAndClientId(tokenId, clientId);
}
private String extractTokenId(String value) {
try {
MessageDigest digest = MessageDigest.getInstance("MD5");
byte[] bytes = digest.digest(value.getBytes("UTF-8"));
return String.format("%032x", new BigInteger(1, bytes));
} catch (Exception e) {
throw new IllegalStateException("Error extracting token ID", e);
}
}
@Override
@Transactional
public String logoutAllClients(String clientId, String userName) {
requestLogoutToAllClients(userName);
removeAccessTokens(userName);
Client client = clientRepository.findOne(clientId);
return client.getBaseUri();
}
private void requestLogoutToAllClients(String userName) {
List<AccessToken> tokens = accessTokenRepository.findByUserName(userName);
for (AccessToken token : tokens) {
requestLogoutToClient(token);
}
}
private void requestLogoutToClient(AccessToken token) {
Client client = clientRepository.findOne(token.getClientId());
String logoutUri = client.getLogoutUri();
Map<String, String> paramMap = new HashMap<>();
paramMap.put("tokenId", token.getTokenId());
paramMap.put("userName", token.getUserName());
// Send HTTP request to logout
}
}
The getAccessToken()
method for retrieving the access token is implemented to handle the following tasks:
- Converts the received access token value into an MD5 hash.
- Calls the
findByTokenIdAndClientId()
method of theAccessTokenRepository
object with the converted hash value and client ID as arguments to retrieve theAccessToken
object stored in the database. - Returns the retrieved
AccessToken
object.
The logoutAllClients()
method, which handles the logout of a user logged in via SSO, performs the following tasks:
- Uses the
findByUserName()
method of theAccessTokenRepository
object to retrieve theAccessToken
objects issued for the user by their username. - Calls the
findOne()
method of theClientRepository
with the client ID of the retrievedAccessToken
object to get the correspondingClient
object. - Sends an HTTP POST request with the token ID and username to the
logoutUri
of theClient
. - Calls the
deleteByUserName()
method of theAccessTokenRepository
object to delete the stored access tokens.
2.1.11. Controller
We implement a controller to manage user info retrieval and logout functionality.
@Controller
public class SsoController {
@Autowired
private SsoService ssoService;
@RequestMapping(value="/userInfo", method=RequestMethod.POST)
@ResponseBody
public UserInfoResponse userInfo(@RequestParam(name="token") String token,
@RequestParam(name="clientId") String clientId) {
AccessToken accessToken = ssoService.getAccessToken(token, clientId);
UserInfoResponse response = new UserInfoResponse();
if (accessToken == null) {
response.setResult(false);
response.setMessage("사용자 정보를 조회할 수 없습니다.");
} else {
response.setUserName(accessToken.getUserName());
}
return response;
}
@RequestMapping(value="/userLogout", method=RequestMethod.GET)
public String userLogout(@RequestParam(name="clientId") String clientId, HttpServletRequest request) {
String userName = request.getRemoteUser();
String baseUri = ssoService.logoutAllClients(clientId, userName);
request.getSession().invalidate();
return "redirect:" + baseUri;
}
}
The userInfo()
method, which handles retrieving the user information of the client that has been issued an access token, calls the getAccessToken()
method of the SsoService
to retrieve the AccessToken
object. It then sets the result into the UserInfoResponse
object and returns it.
The userLogout()
method, which handles the client's logout request, calls the logoutAllClients()
method of the SsoService
and then calls the invalidate()
method of the session to invalidate the authenticated session in the user's browser.
2.2. Client System
The items to be implemented in the client system to set up an SSO environment are as follows:
- Web request handling to redirect to the SSO server's Authorization Endpoint during an SSO request
- Web request handling for redirection that includes the authorization code after the client authorization process on the SSO server
- Web request handling for user logout
- Web request handling for logout on the SSO server
Next, we will look at how these web requests are implemented step by step using Spring MVC.
2.2.1. Redirecting to Authorization Server
@RequestMapping(value="/sso", method=RequestMethod.GET)
public String sso(HttpServletRequest request) {
//
String state = UUID.randomUUID().toString();
request.getSession().setAttribute("oauthState", state);
StringBuilder builder = new StringBuilder();
builder.append("redirect:");
builder.append("http://localhost:8080/oauth/authorize");
builder.append("?response_type=code");
builder.append("&client_id=");
builder.append(getOAuthClientId());
builder.append("&redirect_uri=");
builder.append(getOAuthRedirectUri());
builder.append("&scope=");
builder.append("read");
builder.append("&state=");
builder.append(state);
return builder.toString();
}
Before redirecting to the SSO server, a random state value is generated and stored in the session with the key "oauthState." After configuring the redirect URI with parameters such as responsetype, clientid, redirect_uri, scope, and state, the constructed redirect URI is returned.
2.2.2. Handling Redirect Web Request with Authorization Code
@RequestMapping(value="/oauthCallback", method=RequestMethod.GET)
public String oauthCallback(@RequestParam(name="code") String code,
@RequestParam(name="state") String state,
HttpServletRequest request, ModelMap map) {
//
String oauthState = (String)request.getSession().getAttribute("oauthState");
request.getSession().removeAttribute("oauthState");
TokenRequestResult tokenRequestResult = null;
if (oauthState == null || oauthState.equals(state) == false) {
//
tokenRequestResult = new TokenRequestResult();
tokenRequestResult.setError("not matched state");
}
else {
tokenRequestResult = oauthService.requestAccessTokenToAuthServer(code, request);
}
if (tokenRequestResult.getError() == null) {
return "redirect:/me";
}
else {
map.put("result", tokenRequestResult);
return "authResult";
}
}
This request, redirected from the SSO server, passes the code
and state
values as parameters. First, the stored oauthState
value in the session is checked against the state
value passed from the SSO server. Only if they match, the code
and request
are passed as parameters to the requestAccessTokenToAuthServer()
method in the AuthService
.
public TokenRequestResult requestAccessTokenToAuthServer(String code,
HttpServletRequest request) {
//
TokenRequestResult tokenRequestResult = requestAccessTokenToAuthServer(code);
if (tokenRequestResult.getError() != null) {
return tokenRequestResult;
}
UserInfoResponse userInfoResponse =
requestUserInfoToAuthServer(tokenRequestResult.getAccessToken());
if (userInfoResponse.getResult() == false) {
//
tokenRequestResult.getError(userInfoResponse.getMessage());
return tokenRequestResult;
}
User user = userService.getUser(userInfoResponse.getUserName());
request.getSession().setAttribute("user", user);
userService.updateTokenId(user.getUserName(),
extractTokenId(tokenRequestResult.getAccessToken()));
return tokenRequestResult;
}
In the requestAccessTokenToAuthServer()
method of AuthService
, the request is processed as follows:
- The
code
is passed to therequestAccessTokenToAuthServer()
method, which sends a request to the SSO server's token endpoint and receives the result in aTokenRequestResult
object. - If there is an error in the request, the
TokenRequestResult
object is returned immediately. - The access token received from the SSO server's token endpoint is passed to
requestUserInfoToAuthServer()
to send a request to the User Info endpoint, and the result is stored in aUserInfoResponse
object. - If there is an error in this request, an error message is set in the
TokenRequestResult
object and returned immediately. - The
getUser()
method ofUserService
is called to retrieve theUser
object, which is then stored in the session. - The access token is MD5 hashed and the
updateTokenId()
method ofUserService
is called to store it in the database.
Here are the methods that send the requests to the SSO server's token and user info endpoints:
private TokenRequestResult requestAccessTokenToAuthServer(String code) {
//
String reqUrl = "http://localhost:8080/oauth/token";
String authorizationHeader = getAuthorizationRequestHeader();
Map<String, String> paramMap = new HashMap<>();
paramMap.put("grant_type", "authorization_code");
paramMap.put("redirect_uri", getOAuthRedirectUri());
paramMap.put("code", code);
HttpPost post = buildHttpPost(reqUrl, paramMap, authorizationHeader);
TokenRequestResult result = executePostAndParseResult(post, TokenRequestResult.class);
return result;
}
private User requestUserInfoToAuthServer(String token) {
//
String reqUrl = "http://localhost:8080/userInfo";
String authorizationHeader = null;
Map<String, String> paramMap = new HashMap<>();
paramMap.put("token", token);
paramMap.put("clientId", getOAuthClientId());
HttpPost post = buildHttpPost(reqUrl, paramMap, authorizationHeader);
User result = executePostAndParseResult(post, User.class);
return result;
}
The key point in the definition of these two methods is the setting of the Authorization header for client authentication when sending a request to the token endpoint.
2.2.3. Handling User Logout Web Request
When a logout request is received from the user, the client ID is set as a parameter and a redirect is made to the SSO server's logout endpoint.
@RequestMapping(value="/logout", method=RequestMethod.GET)
public String logout() {
//
return "redirect:http://localhost:8080/userLogout?clientId=" + getOAuthClientId();
}
2.2.4. Handling SSO Server Logout Web Request
@RequestMapping(value="/logout", method=RequestMethod.POST)
@ResponseBody
public Response logoutFromAuthServer(
@RequestParam(name="tokenId") String tokenId,
@RequestParam(name="userName") String userName) {
//
Response response = oauthService.logout(tokenId, userName);
return response;
}
When a logout request is received from the SSO server, the logout()
method of AuthService
is called to perform the following tasks:
- Retrieve user information.
- Check the validity of the access token.
- Delete the access token information for the retrieved user.
3. Conclusion
In this section, we have reviewed OAuth 2.0 and described how to extend it for an SSO environment. We also covered how to set up an SSO environment using Spring Boot and Spring Security OAuth.
Source Code
The source code can be found at the following site:
https://github.com/nextreesoft/oauth
References and Sites
- Ryan Boyd. (2012). Client Programming for Secure API Authentication and Authorization: OAuth 2.0 (translated by Lee Jung-rim). Seoul: Hanbit Media.
- Justin Richer, Antonio Sanso. (2016). OAuth 2 in Action. Manning Publications.
- “The OAuth 2.0 Authorization Framework”. October, 2012. https://tools.ietf.org/html/rfc6749
- “An Introduction to OAuth 2”. July 21, 2014. https://www.digitalocean.com/community/tutorials/an-introduction-to-oauth-2
- “OAuth 2.0 API”. http://developer.okta.com/docs/api/resources/oauth2.html#basic-flows
- “Understanding OAuth2”. January 22, 2016. http://www.bubblecode.net/en/2016/01/22/understanding-oauth2/