Implementation of front and back end separate single sign on based on CAS

preface

SSO (single single on) single sign on is an architecture and an idea.

CAS (Center Authentication Server) center authorization service is an open source protocol and a specific implementation of SSO. Of course, SSO has other implementations, such as the scenario of Cookier with the same domain name.

Auth is an authorization protocol, which does not involve specific code, but only represents an agreed process and specification. Oauth protocol is generally used by users to decide whether to authorize their resources (avatars, photos and other resources) on a service to third-party applications. In addition, Oauth 2.0 protocol is an upgraded version of Oauth protocol, which has gradually become the standard of single sign on (SSO) and user authorization.

Oauth2.0 can also enable SSO, but it is different from CAS.

  • Protection focus: CAS focuses on ensuring the security of user resources at the client, while Oauth2 ensures the security of user resources at the server.
  • Usage scenario: oauth2 0 is a simple authorization method for login between different products of different companies. CAS usually deals with application access and login between different applications of the same company. If an enterprise has multiple subsystems, it only needs to log in to one system to realize the jump between different systems and avoid the problem of repeated login.

Let's take a look at the CAS certification flow chart

Excerpt from CAS official documents (CAS documents)[ https://apereo.github.io/cas/6.2.x/protocol/CAS-Protocol.html ]

After carefully reading the above flow chart, I believe I must have known the process of CAS, but I will briefly describe it in a few words.

  • The browser initiates a request to the back-end service, which determines whether the current user logs in
    • If you have not logged in, bring a cookie (TGC) and redirect to the central authentication server
      • If the central authentication server obtains the corresponding Session data according to the TGC value, it indicates that the current user has logged in, and generates an ST redirect back to the URL requested by the browser
      • On the contrary, the login page is displayed, allowing the user to submit the login after entering the user name and password. If the user name and password are correct, a TGC is generated after caching the Session information, and a ticket ST is generated at the same time to redirect back to the URL requested by the browser
    • If you have logged in, generate an ST redirect back to the URL requested by the browser

Problems encountered under

At present, our company has many products, all of which have a set of recorded user management system. When platform users use various products, they need to log in and interact between different subsystems, and the user feedback operation is not friendly. Therefore, the company needs to realize the single sign on function. At the same time, it needs to be compatible with the authorization and authentication processes of the original subsystem, that is, the single sign on service value can provide authentication services.

I found a lot of information about this on the Internet. No more than two sounds, oauth2 0 and CAS, and most of them introduce how to implement them in the case of single application, There is little information on how to separate the front and rear ends, which is very clear (of course, I may be stupid and can't understand it). Moreover, in the Spring project, the implementation based on Spring security Oauth2.0 is very cumbersome and inflexible when it comes to how to thread the authorization process of the existing subsystem. Therefore, after discussing with the leaders, I decided to develop a set of authentication services based on the idea of CAS and Oauth2.0.

Front and rear end separation scene

Redirection problem

In the scenario of front end and back end separation, one of the major problems encountered is the problem of redirection. The front-end request requests the back-end service through ajax. At this time, the back-end service cannot directly redirect to the authentication server. It can only return the corresponding json data and prompt the front-end to redirect to the central authentication server.

safety problem

The browser side needs to be able to directly access the central authentication server. In order to limit the access of some browsers, some cannot. We need to set two parameters, ClientID (indicating who the client is) and secrect (key). When the browser redirects to the authentication server, we need to bring these two parameters. The user authentication server determines whether the current browser client can access single sign on.

process design

At present, what I have implemented is only a basic usable version, which is just shared for your reference. There are also various details. Please comment and leave a message

code implementation

Server

AuthEndpoint.java

package com.pkit;

import cn.hutool.core.util.IdUtil;
import cn.hutool.core.util.StrUtil;
import cn.hutool.json.JSONUtil;
import com.pkit.service.ISsoClientConfigService;
import com.pkit.service.IUserService;
import com.pkit.vo.SsoUserVO;
import org.springframework.data.redis.core.StringRedisTemplate;
import org.springframework.http.HttpStatus;
import org.springframework.http.ResponseEntity;
import org.springframework.stereotype.Controller;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.PostMapping;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RequestParam;
import org.springframework.web.servlet.ModelAndView;

import javax.servlet.http.Cookie;
import javax.servlet.http.HttpServletRequest;
import javax.servlet.http.HttpServletResponse;
import java.io.IOException;
import java.util.HashMap;
import java.util.Map;
import java.util.Objects;
import java.util.concurrent.TimeUnit;

/**
 * Authentication related endpoints
 *
 * @author zhuxy  zhuxy@pukkasoft.cn
 * @date 2021-08-26 11:05
 */
@Controller
@RequestMapping("/pkit")
public class AuthEndpoint {


    private final static String TGC = "CASTGC";
    private final static String TGC_REDIS_PREFIX = "CASTGC-";
    private final static String SSO_TOKEN_REDIS_PREFIX = "SSO-TOKEN-";
    private final static String CODE_REDIS_PREFIX = "CODE-";

    private final static int COOKIE_EXPIRE_TIME = 60 * 60 * 24;
    private final static int TOKEN_EXPIRE_TIME = 60 * 60 * 8;

    private final StringRedisTemplate redisTemplate;

    private final IUserService userService;
    private final ISsoClientConfigService clientConfigService;

    public AuthController(StringRedisTemplate redisTemplate, IUserService userService,
                          ISsoClientConfigService clientConfigService) {
        this.redisTemplate = redisTemplate;
        this.userService = userService;
        this.clientConfigService = clientConfigService;
    }

    /**
     * Jump to login page
     *
     * @return Landing page
     */
    @GetMapping("/login")
    public ModelAndView loginPage(HttpServletRequest request, HttpServletResponse response, ModelAndView modelAndView) throws IOException {

        String clientId = request.getParameter("clientId");
        String secrete = request.getParameter("secret");

        modelAndView.setViewName("login");

        if (!clientConfigService.isLegalClient(clientId, secrete, modelAndView)) {

            return modelAndView;
        }

        Cookie[] cookies = request.getCookies();
        Cookie cookie = findCookieByName(cookies);
        if (cookie == null) {
            return modelAndView;
        }
        String value = cookie.getValue();
        String userInfo;
        // Whether the cookie is invalid
        if ((userInfo = redisTemplate.opsForValue().get(TGC_REDIS_PREFIX + value)) == null) {
            return modelAndView;
        }

        String redirectUrl = request.getParameter("redirectUrl");

        if (StrUtil.isBlank(redirectUrl)) {
            modelAndView.setViewName("index");
            return modelAndView;
        }

        String code = IdUtil.simpleUUID().toLowerCase();
        setResponseWithAuthorization(code, userInfo);

        response.sendRedirect(redirectUrl + "?code=" + code);
       
        return modelAndView;
    }

    @PostMapping("/action/login")
    public String login(String userName, String password, String redirectUrl,
                        String clientId, HttpServletResponse response) throws IOException {

        SsoUserVO ssoUserVO = userService.login(userName, password, clientId);

        if (StrUtil.isBlank(redirectUrl)) {
            return "index";
        }
        String userInfo = JSONUtil.toJsonStr(ssoUserVO);
        String cookieValue = IdUtil.simpleUUID().toLowerCase();

        String code = IdUtil.simpleUUID().toLowerCase();

        setResponseWithAuthorizationAndCookie(response, code, userInfo,
                                              TGC, cookieValue, "/", COOKIE_EXPIRE_TIME);


        response.sendRedirect(redirectUrl + "?code=" + code);
        return null;
    }

    /**
     * Exchange token with authorization code
     *
     * @param code Authorization code
     * @return accessToken
     */
    @GetMapping("/accessToken")
    public ResponseEntity<Map<String, Object>> accessToken(@RequestParam String code) {
        String userInfo;

        if ((userInfo = redisTemplate.opsForValue().get(CODE_REDIS_PREFIX + code)) == null) {
            return ResponseEntity.status(HttpStatus.UNAUTHORIZED).build();
        }

        String accessToken = IdUtil.simpleUUID().toLowerCase();
        //Issue token
        redisTemplate.opsForValue().set(SSO_TOKEN_REDIS_PREFIX + accessToken, userInfo, TOKEN_EXPIRE_TIME, TimeUnit.SECONDS);

        HashMap<String, Object> result = new HashMap<>();
        result.put("accessToken", accessToken);
        result.put("code", 1000);
        return ResponseEntity.ok(result);
    }
    
	/**
	* SSO-Client Call this interface to check whether the accessToken is legal
	*
	*/
    @GetMapping("/token/validate")
    public ResponseEntity<String> validate(@RequestParam String accessToken) {
        String userInfo;
        if ((userInfo = redisTemplate.opsForValue().get(SSO_TOKEN_REDIS_PREFIX + accessToken)) == null) {
            return ResponseEntity.status(HttpStatus.UNAUTHORIZED).build();
        }
        Long expire = redisTemplate.opsForValue().getOperations().getExpire(SSO_TOKEN_REDIS_PREFIX + accessToken, TimeUnit.SECONDS);
        SsoUserVO userVO = JSONUtil.toBean(userInfo, SsoUserVO.class);
        userVO.setExpireTime(expire == null ? 10 : expire);
        return ResponseEntity.ok(JSONUtil.toJsonStr(userVO));
    }


    /**
     * Find TGC cookies
     *
     * @param cookies Request Cookies in
     * @return Corresponding Cookie
     */
    private Cookie findCookieByName(Cookie[] cookies) {
        if (Objects.isNull(cookies)) {
            return null;
        }
        for (Cookie cookie : cookies) {
            if (AuthController.TGC.equals(cookie.getName())) {
                return cookie;
            }
        }
        return null;
    }

    private void setResponseWithAuthorization(String code, String userInfo) {
        redisTemplate.opsForValue().set(CODE_REDIS_PREFIX + code, userInfo, 5, TimeUnit.SECONDS);

    }
	
    private void setResponseWithAuthorizationAndCookie(HttpServletResponse response, String code, String userInfo,String cookieName, String cookieValue,String path, int cookieExpireTime) {
        setResponseWithAuthorization(code, userInfo);
        Cookie cookie = new Cookie(cookieName, cookieValue);
        cookie.setMaxAge(cookieExpireTime);
        cookie.setPath(path);
        response.addCookie(cookie);
        redisTemplate.opsForValue().set(TGC_REDIS_PREFIX + cookieValue, userInfo, cookieExpireTime, TimeUnit.SECONDS);
    }

Four endpoints are exposed

  • /Login user access jumps to the platform login page
  • /The action/login login page is the endpoint that submits the login request and handles the user login logic (only authentication)
  • /The accessToken client uses the authorization code to exchange the accessToken
  • /The token/validate client verifies the accessToken in order to synchronize the accessToken with the server

When you see here, you must have the following questions:

  • Why is there no endpoint to log out?
    • A: Oh, I didn't have time to do it. Smart people can realize it by themselves (very simple)
  • Why not directly return the accessToken when redirecting back to the client? Instead, return code first and use code to obtain accessToken?
    • A: it's mainly for security reasons, because when redirecting back, you can only bring the data in the URL (PS: if you have a better way to redirect the data, please leave a comment, MEDA). If you do so, the probability of accessToken leakage is very high.

This is the core code of the server. In fact, it is not difficult. There is a complete code at the end of the text

client

On the client side, we need to configure the endpoint address of the server side, intercept the global interface, and uniformly judge whether each access request has been authenticated. So obviously, we need a global Interceptor.

pom.xml

<dependency>
    <groupId>org.springframework.boot</groupId>
    <artifactId>spring-boot-starter-web</artifactId>
    <optional>true</optional>
</dependency>

<dependency>
    <groupId>org.springframework.boot</groupId>
    <artifactId>spring-boot-starter-data-redis</artifactId>
    <optional>true</optional>
</dependency>

<dependency>
    <groupId>org.springframework.boot</groupId>
    <artifactId>spring-boot-configuration-processor</artifactId>
    <optional>true</optional>
</dependency>

<dependency>
    <groupId>cn.hutool</groupId>
    <artifactId>hutool-all</artifactId>
    <version>5.7.9</version>
</dependency>

<dependency>
    <groupId>org.projectlombok</groupId>
    <artifactId>lombok</artifactId>
    <optional>true</optional>
</dependency>

application.yml

pkit:
  sso:
    client:
      clientId: client1
      secrete: 123456
    server:
      loginUrl: http://192.168.0.1:8888/pkit/login # single sign on address
      tokenUrl: http://192.168.0.1:8888/pkit/accesstoken # get the token address
      tokenValidate: http://192.168.0.1:8888/pkit/token/validate # verify the token address
# Redis is used as the local Session cache management by default
spring:
  redis:
    password: PUKKA028
    host: 192.168.0.1
    database: 1

RestAuthenticationInterceptor.java

package com.pkit.framework.interceptor;

import cn.hutool.core.bean.BeanUtil;
import cn.hutool.core.util.StrUtil;
import cn.hutool.http.HttpRequest;
import cn.hutool.http.HttpResponse;
import cn.hutool.http.HttpUtil;
import com.pkit.framework.config.SSOProperties;
import com.pkit.framework.exception.BizException;
import com.pkit.framework.exception.BizExceptionEnum;
import com.pkit.framework.vo.SsoUserVO;
import lombok.Getter;
import lombok.Setter;
import org.springframework.data.redis.core.StringRedisTemplate;
import org.springframework.stereotype.Component;
import org.springframework.web.servlet.HandlerInterceptor;

import javax.servlet.http.HttpServletRequest;
import javax.servlet.http.HttpServletResponse;
import java.util.concurrent.TimeUnit;

/**
 * Authentication interceptor
 *
 * @author zhuxy  zhuxy@pukkasoft.cn
 * @date 2021-08-30 14:10:25
 */
@Component
public class RestAuthenticationInterceptor implements HandlerInterceptor {

    private final SSOProperties ssoProperties;
    private final StringRedisTemplate redisTemplate;
    public RestAuthenticationInterceptor(SSOProperties ssoProperties, StringRedisTemplate redisTemplate) {
        this.ssoProperties = ssoProperties;
        this.redisTemplate = redisTemplate;
    }

    @Override
    public boolean preHandle(HttpServletRequest request, HttpServletResponse response, Object handler) throws Exception {
        String accessToken = request.getHeader("Authorization");

        String clientId = ssoProperties.getClient().getClientId();
        String secret = ssoProperties.getClient().getSecret();
        String loginUrl = ssoProperties.getServer().getLoginUrl();
        String tokenValidateUrl = ssoProperties.getServer().getTokenValidate();

        //Not logged in
        if (StrUtil.isBlank(accessToken)) {
            //Return redirection and notify the client to redirect to loginUrl
            RedirectUrlInfo redirectUrlInfo = new RedirectUrlInfo(loginUrl, clientId, secret);
            throw new BizException(BizExceptionEnum.REDIRECT,redirectUrlInfo);
        }
        //Query whether the local cache has Session information
        String sessionInfo = redisTemplate.opsForValue().get("Authorization-" + accessToken);
        SsoUserVO userVO ;
        if (StrUtil.isBlank(sessionInfo)) {
            //Query whether you have logged in to the authentication server
            HttpRequest getRequest = HttpUtil.createGet(tokenValidateUrl);
            getRequest.header("accessToken",accessToken);
            HttpResponse httpResponse = getRequest.execute();
            //Logged in to the authentication server
            if (httpResponse.isOk()){
                String body = httpResponse.body();
                userVO = BeanUtil.toBean(body, SsoUserVO.class);
                redisTemplate.opsForValue().set("Authorization-" + accessToken,body,userVO.getExpireTime(), TimeUnit.MILLISECONDS);
            }else{
                //Return redirection and notify the client to redirect to loginUrl
                RedirectUrlInfo redirectUrlInfo = new RedirectUrlInfo(loginUrl, clientId, secret);
                throw new BizException(BizExceptionEnum.REDIRECT,redirectUrlInfo);
            }
        }else{
            userVO = BeanUtil.toBean(sessionInfo,SsoUserVO.class);
        }
        request.setAttribute("clientUserName",userVO.getClientName());
        request.setAttribute("accessToken",accessToken);
        return true;
    }


    @Getter
    @Setter
    static class RedirectUrlInfo {
        private String loginUrl;
        private String clientId;
        private String secrete;

        public RedirectUrlInfo(String loginUrl, String clientId, String secrete) {
            this.loginUrl = loginUrl;
            this.clientId = clientId;
            this.secrete = secrete;
        }
    }
}

The comments in the above code are very detailed. I'd like to briefly describe the process below:

Judge whether there is an agreed request header Authorization in the user request header

  1. If not, it means that the user has not logged in. The 1302 status code is directly returned, redirecting Url, clientId, secret, and prompting the front end to use window location. Href redirect to the login page on the Server side

  2. If the request header parameter exists, first query whether there is a corresponding Session from the local Session cache

    1. If there is a current Session in the local Session cache, take out the Session information and encapsulate it as a SsoUser object. Pass the object to the next interceptor for permission verification.
    2. If the current Session does not exist in the local Session cache, initiate the / token/validate endpoint on the Server side of the Rest request.
      • If the 200 status code is responded and the accessToken parameter is returned, store the accessToken in the local cache and execute 2.1.
      • If a 401 status code is responded to, perform 1

Of course, many of the above steps involve front-end development XD, so it is necessary for you to explain the process for them. A simple single point service has been built. You are welcome to leave a message below.

Code address

Keywords: Java http

Added by jonzy on Tue, 14 Dec 2021 23:19:57 +0200