SSO Single Sign-on (Client) Based on Spring Security + OAuth2

Posted by Flukey on Thu, 22 Aug 2019 13:18:49 +0200

1. Origin

Why do you want to take the client out and write it separately?
Bloggers also refer to a lot of single sign-on on the Internet, but they are basically the same and the same. In the client's own privilege checking and single exit are not processed, which shows that it does not meet the actual business development.

2. Core process

Client login: The user visits the client, the client security finds that the user who requests this is not logged in, and then redirects the request to the server for authentication. The server detects that the user who requests this is not logged in, and then jumps the request to the login page provided by the server (the front-end login address is separated from the back-end, otherwise serves as a service). After successful login, the server stores the privilege information of the system (in order to alleviate the access pressure of the server) and the user's unique logo (such as user name, record the login status of the user) into redis, and then the server jumps back to the page where the user first visits the client.

Client URL interception: Every time a request arrives, the client goes to Redis to get the authority information stored in the authentication center and the user-specific logon logo. The authority information is only to match whether the logged-in user has the right to access the interface. The user's unique logo is to detect whether the user exits from other clients. If not, redirect to the server's login page.

3. Necessary dependency

<!--  Integrate SSO rely on  -->
<dependency>
    <groupId>org.springframework.security.oauth.boot</groupId>
    <artifactId>spring-security-oauth2-autoconfigure</artifactId>
    <version>2.1.3.RELEASE</version>
</dependency>
<!--  redis Necessary dependency  -->
<dependency>
    <groupId>org.springframework.boot</groupId>
    <artifactId>spring-boot-starter-data-redis</artifactId>
    <version>2.1.4.RELEASE</version>
</dependency>

4. Introduction to configuration

4.1 security Core Configuration

@Configuration
@EnableOAuth2Sso
public class ClientWebsecurityConfigurer extends WebSecurityConfigurerAdapter {

    @Autowired
    @Qualifier("urlFilterInvocationSecurityMetadataSource")
    UrlFilterInvocationSecurityMetadataSource urlFilterInvocationSecurityMetadataSource;

    @Autowired
    @Qualifier("urlAccessDecisionManager")
    AccessDecisionManager urlAccessDecisionManager;

    @Autowired
    @Qualifier("securityAccessDeniedHandler")
    private AccessDeniedHandler securityAccessDeniedHandler;

    @Autowired
    @Qualifier("securityAuthenticationEntryPoint")
    private AuthenticationEntryPoint securityAuthenticationEntryPoint;

    @Value("${auth-server}")
    public String auth_server;

    @Bean
    public RestTemplate restTemplate(){
        return new RestTemplate();
    }

     /**
     * Release static resources
     */
    @Override
    public void configure(WebSecurity web) {
        web.ignoring().antMatchers(
                "/css/**",
                 "/js/**",
                 "/favicon.ico",
                  "/static/**",
                  "/error");
    }

    @Override
    public void configure(HttpSecurity http) throws Exception {
        http
                .authorizeRequests()
                .antMatchers("/", "/login").permitAll()
                .anyRequest().authenticated()
                .withObjectPostProcessor(urlObjectPostProcessor());

        http
                .exceptionHandling()
                .authenticationEntryPoint(securityAuthenticationEntryPoint)
                .accessDeniedHandler(securityAccessDeniedHandler);

        http.
                logout()
                .logoutSuccessUrl(auth_server + "/logout")
                .deleteCookies("JSESSIONID");

        // Failure to do so will result in exit that does not support GET
        http.csrf().disable();
    }

    public ObjectPostProcessor urlObjectPostProcessor() {
        return new ObjectPostProcessor<FilterSecurityInterceptor>() {
            @Override
            public <O extends FilterSecurityInterceptor> O postProcess(O o) {
                o.setSecurityMetadataSource(urlFilterInvocationSecurityMetadataSource);
                o.setAccessDecisionManager(urlAccessDecisionManager);
                return o;
            }
        };
    }
}

Configuration description:
withObjectPostProcessor(urlObjectPostProcessor()); This configuration indicates that spring-security's custom checking is enabled. To achieve the custom checking of URLs, the core is urlFilterInvocation Security Metadata Source, urlAccessDecision Manager. The first one is to get the G needed to access the URLs. RantedAuthority (i.e. which roles are required). The second main function is to compare whether a user's GrantedAuthority (user-owned roles) contains the GrantedAuthority (role group) required by this URL. Access is allowed as long as there is a match, and no match means no privilege.

4.2 Configuration of Custom FilterInvocation Security Metadata Source

/**
 * @author lirong
 * @ClassName: UrlFilterInvocationSecurityMetadataSource
 * @Description: Get the set of roles needed to access this URL and
 * @date 2019-07-10 14:36
 */
@Component("urlFilterInvocationSecurityMetadataSource")
@Slf4j
public class UrlFilterInvocationSecurityMetadataSource implements FilterInvocationSecurityMetadataSource {

    @Autowired
    private RedisTemplate redisTemplate;

    public Collection<ConfigAttribute> getAttributes(Object o) throws IllegalArgumentException {

        HttpServletRequest request = ((FilterInvocation) o).getHttpRequest();

        // Get the logon flag of the user in Redis to determine if the user has logged out from another client
        Authentication authentication = SecurityContextHolder.getContext().getAuthentication();
        String username = (String) authentication.getPrincipal();
        String isLogin = (String) redisTemplate.opsForValue().get(Constant.REDIS_PERM_KEY_PREFIX + username);
        if(StringUtils.isEmpty(isLogin)){
            throw new AccountExpiredException("Users have exited from other clients");
        }

        // Get the set of roles required for this URL
        List<Map<String, String[]>> menuMap = (List<Map<String, String[]>>) redisTemplate.opsForValue().get(Constant.REDIS_PERM_KEY_PREFIX);
        if (null != menuMap) {
            for (Map<String, String[]> map : menuMap) {
                for (String url : map.keySet()) {
                    String[] split = url.split(":");
                    AntPathRequestMatcher antPathMatcher = new AntPathRequestMatcher(split[0], split[1]);
                    if(antPathMatcher.matches(request)){
                        return SecurityConfig.createList(map.get(url));
                    }
                }
            }
        }
        // No matching resources, all login access
        return SecurityConfig.createList("ROLE_LOGIN");
    }

    public Collection<ConfigAttribute> getAllConfigAttributes() {
        return null;
    }

    public boolean supports(Class<?> aClass) {
        return false;
    }
}

Why return ROLE_LOGIN?
ROLE_LOGIN, as the name implies, can be accessed only by login, and finally returns only to add a layer of checking to the URLs that are not included in the privilege table. Of course, you can also return null directly, so that access without matching URLs will not be restricted by security.

4.3 Configuration of Custom Access Decision Manager

@Component("urlAccessDecisionManager")
public class UrlAccessDecisionManager implements AccessDecisionManager {

    @Autowired
    private RedisTemplate redisTemplate;

    public void decide(Authentication auth, Object o, Collection<ConfigAttribute> collection) throws AccessDeniedException, AuthenticationException {
  
        Collection<? extends GrantedAuthority> authorities = auth.getAuthorities();
        Iterator<ConfigAttribute> iterator = collection.iterator();
        while (iterator.hasNext()) {
            ConfigAttribute ca = iterator.next();
            //The permissions required for the current request
            String needRole = ca.getAttribute();
            if ("ROLE_LOGIN".equals(needRole)) {
                if (auth instanceof AnonymousAuthenticationToken) {
                    throw new BadCredentialsException("User not logged in");
                } else {
                    return;
                }
            }
            //Permissions of the current user
            for (GrantedAuthority authority : authorities) {
                if (authority.getAuthority().equals(needRole)) {
                    return;
                }
            }
        }
        throw new AccessDeniedException("Insufficient authority!");
    }

    public boolean supports(ConfigAttribute configAttribute) {
        return true;
    }
    public boolean supports(Class<?> aClass) {
        return true;
    }
}

4.4 User Login Processing

/**
 * Processing when the user is not logged in
 * @author lirong
 * @date 2019-8-8 17:37:27
 */
@Component("securityAuthenticationEntryPoint")
@Slf4j
public class SecurityAuthenticationEntryPoint implements AuthenticationEntryPoint {

	@Value("${auth-server}")
	public String auth_server;
	
	@Override
	public void commence(HttpServletRequest request, HttpServletResponse response, AuthenticationException authException) throws IOException {
		log.info("Not logged in yet:" + authException.getMessage());
		response.sendRedirect(auth_server + "/login");
	
		// It can also return the JSON status code directly, so that the front end can jump directly. This method can deal with the problem that circular redirection can not access the login page.
		// ResponseUtils.renderJson(request, response, ResultCode.UNLOGIN, null);
	}
}

Configuration instructions
About the jump of the login page, it's better to leave it to the front-end to do. If the back-end jumps, the problem of circular redirection may occur after the session fails, and then it can't jump to the login page. Welcome to discuss different solutions.

4.5 Processing of Users Without Privileges

/**
 * Processing of User Access to Unauthorized Resources
 * @author lirong
 * @date
 */
@Component("securityAccessDeniedHandler")
@Slf4j
public class SecurityAccessDeniedHandler implements AccessDeniedHandler {

	@Override
	public void handle(HttpServletRequest request, HttpServletResponse response, AccessDeniedException accessDeniedException){
		log.info(request.getRequestURL()+"No privileges");
		ResponseUtils.renderJson(request, response, ResultCode.LIMITED_AUTHORITY, null);
	}
}

ResponseUtils encapsulates some returned JSON information, including cross-domain request headers, etc.

5. Configuration of client in YML

auth-server: http://192.168.1.201:9999//Address of Certification Center
server:
  port: 8086
  servlet:
    session:
      cookie:
        name: UISESSION

security:
  oauth2:
    client:
      client-id: janche
      client-secret: 123456
      user-authorization-uri: ${auth-server}/oauth/authorize
      access-token-uri: ${auth-server}/oauth/token
    resource:
      jwt:
        key-uri: ${auth-server}/oauth/token_key
      userInfoUri: ${auth-server}/user/oauth/sso
      token-info-uri: ${auth-server}/oauth/check_token

spring:
  #redis
  redis:
    database: 0
    # Redis server address
    host: 192.168.1.201
    port: 6379
    password:
    timeout: 5000ms

    jedis:
      pool:
        # Maximum number of connections in connection pool
        max-active: 8
        # Maximum idle connection in connection pool
        max-idle: 8
        min-idle: 0
        max-wait: -1ms

5. Controller

@Slf4j
@RestController
public class TestController {

    @Autowired
    private RestTemplate restTemplate;

    @Value("${auth-server}")
    public String auth_server;

    @GetMapping("/normal")
    public String normal( ) {
        return "normal permission test success !!!";
    }

    @GetMapping("/medium")
    public String medium() {
        return "mediumpermission test success !!!";
    }

    @GetMapping("/admin")
    public String admin() {
        return "admin permission test success !!!";
    }

    @GetMapping("/user")
    public RestResult getLoginUser(){

        String url = auth_server + "/user/oauth/sso";
        String tokenValue = SecurityUtils.getJwtToken();

        HttpHeaders headers = new HttpHeaders();
        headers.setContentType(MediaType.APPLICATION_JSON);
        headers.set("Authorization", "Bearer " + tokenValue);

        HttpEntity<String> entity = new HttpEntity<>(headers);
        SsoUser user = restTemplate.postForObject(url, entity, SsoUser.class);
        return ResultGenerator.genSuccessResult(user);
    }
}

About Getting Logged-in User Information
Because it is the OAuth client that accesses the server, it must bring the access_token issued by the server to get the user data in the server. Otherwise, the server can't recognize it. It will identify the request as not logged in. I am also a little confused about the configuration of userInfoUri in yml, and the official documents have not given how to use it.

test

The test will be screenshots later. Practice gives us real knowledge. Welcome to leave a message for discussion.

GitHub source address: https://github.com/Janche/sso-oauth2-client

Reference Blog

https://www.baeldung.com/sso-spring-security-oauth2
https://www.linzepeng.com/2018/10/31/sso-note1/

Topics: Redis Spring JSON Session