OAuth2.0协议入门(二):OAuth2.0授权服务端从设计到实现

一 OAuth2.0授权服务端的设计

在《OAuth2.0协议入门(一):OAuth2.0协议的基本概念以及使用授权码模式(authorization code)实现百度账号登录》中,介绍了OAuth2.0协议的基本概念以及作为一个第三方应用在请求授权服务端的时候需要做哪些事情。通过上一篇文章中调用百度OAuth服务的例子我们可以得知,使用授权码模式完成OAuth2.0授权的过程需要以下三个步骤:

  1. client请求授权服务端,获取Authorization Code
  2. client通过Authorization Code再次请求授权服务端,获取Access Token
  3. client通过服务端返回的Access Token获取用户的基本信息

因此,OAuth2.0授权服务端的设计也就主要围绕这几个接口展开,其主要流程是这样的:

明白了整个运行流程,那剩下就好办了。接下来我们需要做的是数据库的表结构设计。

数据库的表结构设计

提示:我在下面只介绍一些表的主要字段,这个Demo中使用的完整的表结构可以参考:https://gitee.com/zifangsky/OAuth2.0Demo/blob/master/rbac_db.sql

(1)auth_client_details:

接入的第三方客户端详情表。这就跟我们要想使用百度OAuth服务就需要事先在百度开发者中心新建一个应用是一个道理,每个想要接入OAuth2.0授权服务的第三方客户端都需要事先在服务端这里“备案”,所以主要需要以下几个字段:

  • client_id:每个客户端的client_id是唯一的,通常是一个随机生成的字符串
  • client_name:客户端的名称
  • client_secret:这个秘钥是客户端和OAuth2.0服务端共同持有,用于鉴别请求中的身份,通常也是一个随机生成的字符串

(2)auth_scope:

用户信息范围表。OAuth2.0服务端在授权第三方客户端访问用户的信息的时候,通常会把用户的信息划分为几个级别,比如用户的基本信息,用户密码、购物记录等高保密性信息。这样划分主要是让用户自主选择把自己哪种信息授权给第三方客户端访问,所以主要需要以下字段:

  • scope_name:范围名称

(3)auth_access_token:

Access Token信息表。这个表主要体现出哪个用户授予哪个client何种访问范围的令牌,以及这个令牌的结束日期是哪天。所以主要需要以下几个字段:

  • access_token:Access Token字段
  • user_id:表明是哪个用户授予的权限
  • client_id:表明授予给哪个客户端
  • expires_in:过期时间戳,表明这个Token在哪一天过期
  • scope:表明可以访问何种范围

(4)auth_refresh_token:

Refresh Token信息表。这个表主要用来记录Refresh Token,在设计表结构的时候需要关联它对应的auth_access_token表的记录。所以主要需要以下几个字段:

  • refresh_token:Refresh Token字段
  • token_id:它对应的auth_access_token表的记录
  • expires_in:过期时间戳

(5)auth_client_user:

用户对某个接入客户端的授权信息表。这个表用于记录client、scope、用户之间的关联关系。所以主要需要以下几个字段:

  • auth_client_id:授权对应的auth_client_details表的记录
  • user_id:授权对应的user表的记录
  • auth_scope_id:授权对应的auth_scope表的记录

明白了授权的整个流程,以及设计好后面需要用到的表结构,那么我们最后就剩下具体代码实现了。

二 OAuth2.0授权服务端主要接口的代码实现

这个Demo的授权服务端的完整可用源码可以参考:https://gitee.com/zifangsky/OAuth2.0Demo/tree/master/ServerDemo

(1)客户端注册接口:

某个第三方客户端需要事先在服务端这里“备案”。在这个Demo中我没有写具体的页面,只提供了一个注册接口,其中client_id和client_secret都是随机生成的字符串。

接口地址:http://127.0.0.1:7000/oauth2.0/clientRegister

参数:

 

1

{"clientName":"测试客户端","redirectUri":"http://localhost:7080/login","description":"这是一个测试客户端服务"}

(2)授权页面:

如果用户之前没有给请求的client授权过,那么在第一次请求Authorization Code的时候会打开授权页面,然后用户手动选择是否授权:

实现代码很简单,就是在用户选择“授权”后,往表auth_client_user插入一条记录。这里就不多说了,可以自行参考一下示例源码。

(3)获取Authorization Code:

根据请求的client_id和scope生成一个字符串——Authorization Code,同时需要将本次请求的授权范围和所属的用户信息保存到Redis中(因为后面在请求Access Token的时候是从第三方客户端的后台直接请求,属于一个新的会话,所以需要提前存一下用户信息)。

接口地址:http://127.0.0.1:7000/oauth2.0/authorize?client_id=7Ugj6XWmTDpyYp8M8njG3hqx&scope=basic&response_type=code&state=AB1357&redirect_uri=http://192.1

/**
 * 获取Authorization Code
 * @author zifangsky
 * @date 2018/8/6 17:40
 * @since 1.0.0
 * @param request HttpServletRequest
 * @return org.springframework.web.servlet.ModelAndView
 */
@RequestMapping("/authorize")
public ModelAndView authorize(HttpServletRequest request){
	HttpSession session = request.getSession();
	User user = (User) session.getAttribute(Constants.SESSION_USER);

	//客户端ID
	String clientIdStr = request.getParameter("client_id");
	//权限范围
	String scopeStr = request.getParameter("scope");
	//回调URL
	String redirectUri = request.getParameter("redirect_uri");
	//status,用于防止CSRF攻击(非必填)
	String status = request.getParameter("status");

	//生成Authorization Code
	String authorizationCode = authorizationService.createAuthorizationCode(clientIdStr, scopeStr, user);

	String params = "?code=" + authorizationCode;
	if(StringUtils.isNoneBlank(status)){
		params = params + "&status=" + status;
	}

	return new ModelAndView("redirect:" + redirectUri + params);
}

调用的cn/zifangsky/service/impl/AuthorizationServiceImpl.java类里面的生成逻辑:

@Override
public String createAuthorizationCode(String clientIdStr, String scopeStr, User user) {
	//1. 拼装待加密字符串(clientId + scope + 当前精确到毫秒的时间戳)
	String str = clientIdStr + scopeStr + String.valueOf(DateUtils.currentTimeMillis());

	//2. SHA1加密
	String encryptedStr = EncryptUtils.sha1Hex(str);

	//3.1 保存本次请求的授权范围
	redisService.setWithExpire(encryptedStr + ":scope", scopeStr, (ExpireEnum.AUTHORIZATION_CODE.getTime()), ExpireEnum.AUTHORIZATION_CODE.getTimeUnit());
	//3.2 保存本次请求所属的用户信息
	redisService.setWithExpire(encryptedStr + ":user", user, (ExpireEnum.AUTHORIZATION_CODE.getTime()), ExpireEnum.AUTHORIZATION_CODE.getTimeUnit());

	//4. 返回Authorization Code
	return encryptedStr;
}

(4)通过Authorization Code获取Access Token:

在第三方客户端拿到Authorization Code后,它就可以在后台调用生成Token的接口,生成Access Token和Refresh Token:

接口地址:http://127.0.0.1:7000/oauth2.0/token?grant_type=authorization_code&code=82ce2bf34f5028d7e8a517ef381f5c87f0139b26&client_id=7Ugj6XWmTDpyYp8M8njG3hqx&client_secret=tur2rlFfywR9OOP3fB5ZbsLTnNuNabI3&redirect_uri=http://192.168.197.130:7080/login

返回如下:

{
	"access_token": "1.6659c9d38f5943f97db334874e5229284cdd1523.2592000.1537600367",
	"refresh_token": "2.b19923a01cf35ccab48ddbd687750408bd1cb763.31536000.1566544316",
	"expires_in": 2592000,
	"scope": "basic"
}
/**
 * 通过Authorization Code获取Access Token
 * @author zifangsky
 * @date 2018/8/18 15:11
 * @since 1.0.0
 * @param request HttpServletRequest
 * @return java.util.Map
 */
@RequestMapping(value = "/token", produces = MediaType.APPLICATION_JSON_UTF8_VALUE)
@ResponseBody
public Map token(HttpServletRequest request){
	Map result = new HashMap<>(8);

	//授权方式
	String grantType = request.getParameter("grant_type");
	//前面获取的Authorization Code
	String code = request.getParameter("code");
	//客户端ID
	String clientIdStr = request.getParameter("client_id");
	//接入的客户端的密钥
	String clientSecret = request.getParameter("client_secret");
	//回调URL
	String redirectUri = request.getParameter("redirect_uri");

	//校验授权方式
	if(!GrantTypeEnum.AUTHORIZATION_CODE.getType().equals(grantType)){
		this.generateErrorResponse(result, ErrorCodeEnum.UNSUPPORTED_GRANT_TYPE);
		return result;
	}

	try {
		AuthClientDetails savedClientDetails = authorizationService.selectClientDetailsByClientId(clientIdStr);
		//校验请求的客户端秘钥和已保存的秘钥是否匹配
		if(!(savedClientDetails != null && savedClientDetails.getClientSecret().equals(clientSecret))){
			this.generateErrorResponse(result, ErrorCodeEnum.INVALID_CLIENT);
			return result;
		}

		//校验回调URL
		if(!savedClientDetails.getRedirectUri().equals(redirectUri)){
			this.generateErrorResponse(result, ErrorCodeEnum.REDIRECT_URI_MISMATCH);
			return result;
		}

		//从Redis获取允许访问的用户权限范围
		String scope = redisService.get(code + ":scope");
		//从Redis获取对应的用户信息
		User user = redisService.get(code + ":user");

		//如果能够通过Authorization Code获取到对应的用户信息,则说明该Authorization Code有效
		if(StringUtils.isNoneBlank(scope) && user != null){
			//过期时间
			Long expiresIn = DateUtils.dayToSecond(ExpireEnum.ACCESS_TOKEN.getTime());

			//生成Access Token
			String accessTokenStr = authorizationService.createAccessToken(user, savedClientDetails, grantType, scope, expiresIn);
			//查询已经插入到数据库的Access Token
			AuthAccessToken authAccessToken = authorizationService.selectByAccessToken(accessTokenStr);
			//生成Refresh Token
			String refreshTokenStr = authorizationService.createRefreshToken(user, authAccessToken);

			//返回数据
			result.put("access_token", authAccessToken.getAccessToken());
			result.put("refresh_token", refreshTokenStr);
			result.put("expires_in", expiresIn);
			result.put("scope", scope);
			return result;
		}else{
			this.generateErrorResponse(result, ErrorCodeEnum.INVALID_GRANT);
			return result;
		}
	}catch (Exception e){
		this.generateErrorResponse(result, ErrorCodeEnum.UNKNOWN_ERROR);
		return result;
	}
}

生成逻辑同样在cn/zifangsky/service/impl/AuthorizationServiceImpl.java这个类里面,具体如下:

@Override
public String createAccessToken(User user, AuthClientDetails savedClientDetails, String grantType, String scope, Long expiresIn) {
	Date current = new Date();
	//过期的时间戳
	Long expiresAt = DateUtils.nextDaysSecond(ExpireEnum.ACCESS_TOKEN.getTime(), null);

	//1. 拼装待加密字符串(username + clientId + 当前精确到毫秒的时间戳)
	String str = user.getUsername() + savedClientDetails.getClientId() + String.valueOf(DateUtils.currentTimeMillis());

	//2. SHA1加密
	String accessTokenStr = "1." + EncryptUtils.sha1Hex(str) + "." + expiresIn + "." + expiresAt;

	//3. 保存Access Token
	AuthAccessToken savedAccessToken = authAccessTokenMapper.selectByUserIdClientIdScope(user.getId()
			, savedClientDetails.getId(), scope);
	//如果存在userId + clientId + scope匹配的记录,则更新原记录,否则向数据库中插入新记录
	if(savedAccessToken != null){
		savedAccessToken.setAccessToken(accessTokenStr);
		savedAccessToken.setExpiresIn(expiresAt);
		savedAccessToken.setUpdateUser(user.getId());
		savedAccessToken.setUpdateTime(current);
		authAccessTokenMapper.updateByPrimaryKeySelective(savedAccessToken);
	}else{
		savedAccessToken = new AuthAccessToken();
		savedAccessToken.setAccessToken(accessTokenStr);
		savedAccessToken.setUserId(user.getId());
		savedAccessToken.setUserName(user.getUsername());
		savedAccessToken.setClientId(savedClientDetails.getId());
		savedAccessToken.setExpiresIn(expiresAt);
		savedAccessToken.setScope(scope);
		savedAccessToken.setGrantType(grantType);
		savedAccessToken.setCreateUser(user.getId());
		savedAccessToken.setUpdateUser(user.getId());
		savedAccessToken.setCreateTime(current);
		savedAccessToken.setUpdateTime(current);
		authAccessTokenMapper.insertSelective(savedAccessToken);
	}

	//4. 返回Access Token
	return accessTokenStr;
}

@Override
public String createRefreshToken(User user, AuthAccessToken authAccessToken) {
	Date current = new Date();
	//过期时间
	Long expiresIn = DateUtils.dayToSecond(ExpireEnum.REFRESH_TOKEN.getTime());
	//过期的时间戳
	Long expiresAt = DateUtils.nextDaysSecond(ExpireEnum.REFRESH_TOKEN.getTime(), null);

	//1. 拼装待加密字符串(username + accessToken + 当前精确到毫秒的时间戳)
	String str = user.getUsername() + authAccessToken.getAccessToken() + String.valueOf(DateUtils.currentTimeMillis());

	//2. SHA1加密
	String refreshTokenStr = "2." + EncryptUtils.sha1Hex(str) + "." + expiresIn + "." + expiresAt;

	//3. 保存Refresh Token
	AuthRefreshToken savedRefreshToken = authRefreshTokenMapper.selectByTokenId(authAccessToken.getId());
	//如果存在tokenId匹配的记录,则更新原记录,否则向数据库中插入新记录
	if(savedRefreshToken != null){
		savedRefreshToken.setRefreshToken(refreshTokenStr);
		savedRefreshToken.setExpiresIn(expiresAt);
		savedRefreshToken.setUpdateUser(user.getId());
		savedRefreshToken.setUpdateTime(current);
		authRefreshTokenMapper.updateByPrimaryKeySelective(savedRefreshToken);
	}else{
		savedRefreshToken = new AuthRefreshToken();
		savedRefreshToken.setTokenId(authAccessToken.getId());
		savedRefreshToken.setRefreshToken(refreshTokenStr);
		savedRefreshToken.setExpiresIn(expiresAt);
		savedRefreshToken.setCreateUser(user.getId());
		savedRefreshToken.setUpdateUser(user.getId());
		savedRefreshToken.setCreateTime(current);
		savedRefreshToken.setUpdateTime(current);
		authRefreshTokenMapper.insertSelective(savedRefreshToken);
	}

	//4. 返回Refresh Token
	return refreshTokenStr;
}

(5)通过Refresh Token刷新Access Token:

当第三方客户端的Access Token失效的时候就可以调用这个接口,重新生成一个新的Access Token:

接口地址:http://127.0.0.1:7000/oauth2.0/refreshToken?refresh_token=2.5c58637a2d51e4470d3e1189978e94da8402785e.31536000.1566283826

返回如下

{
	"access_token": "1.adebb0a4522d5dae9eaf94a5af4fec070c4f3dce.2592000.1537508734",
	"refresh_token": "2.5c58637a2d51e4470d3e1189978e94da8402785e.31536000.1566283826",
	"expires_in": 2592000,
	"scope": "basic"
}
/**
 * 通过Refresh Token刷新Access Token
 * @author zifangsky
 * @date 2018/8/22 11:11
 * @since 1.0.0
 * @param request HttpServletRequest
 * @return java.util.Map
 */
@RequestMapping(value = "/refreshToken", produces = MediaType.APPLICATION_JSON_UTF8_VALUE)
@ResponseBody
public Map refreshToken(HttpServletRequest request){
	Map result = new HashMap<>(8);

	//获取Refresh Token
	String refreshTokenStr = request.getParameter("refresh_token");

	try {
		AuthRefreshToken authRefreshToken = authorizationService.selectByRefreshToken(refreshTokenStr);

		if(authRefreshToken != null) {
			Long savedExpiresAt = authRefreshToken.getExpiresIn();
			//过期日期
			LocalDateTime expiresDateTime = DateUtils.ofEpochSecond(savedExpiresAt, null);
			//当前日期
			LocalDateTime nowDateTime = DateUtils.now();

			//如果Refresh Token已经失效,则需要重新生成
			if (expiresDateTime.isBefore(nowDateTime)) {
				this.generateErrorResponse(result, ErrorCodeEnum.EXPIRED_TOKEN);
				return result;
			} else {
				//获取存储的Access Token
				AuthAccessToken authAccessToken = authorizationService.selectByAccessId(authRefreshToken.getTokenId());
				//获取对应的客户端信息
				AuthClientDetails savedClientDetails = authorizationService.selectClientDetailsById(authAccessToken.getClientId());
				//获取对应的用户信息
				User user = userService.selectByUserId(authAccessToken.getUserId());

				//新的过期时间
				Long expiresIn = DateUtils.dayToSecond(ExpireEnum.ACCESS_TOKEN.getTime());
				//生成新的Access Token
				String newAccessTokenStr = authorizationService.createAccessToken(user, savedClientDetails
						, authAccessToken.getGrantType(), authAccessToken.getScope(), expiresIn);

				//返回数据
				result.put("access_token", newAccessTokenStr);
				result.put("refresh_token", refreshTokenStr);
				result.put("expires_in", expiresIn);
				result.put("scope", authAccessToken.getScope());
				return result;
			}
		}else {
			this.generateErrorResponse(result, ErrorCodeEnum.INVALID_GRANT);
			return result;
		}
	}catch (Exception e){
		this.generateErrorResponse(result, ErrorCodeEnum.UNKNOWN_ERROR);
		return result;
	}
}

(6)通过Access Token获取用户信息:

在通过Access Token获取用户信息的时候,首先需要在拦截器里校验请求的Token是否有效,相关代码逻辑如下:

接口地址:http://127.0.0.1:7000/api/users/getInfo?access_token=1.adebb0a4522d5dae9eaf94a5af4fec070c4f3dce.2592000.1537508734

返回如下

{
	"mobile": "110",
	"id": 1,
	"email": "[email protected]",
	"username": "admin"
}
package cn.zifangsky.interceptor;

import cn.zifangsky.enums.ErrorCodeEnum;
import cn.zifangsky.model.AuthAccessToken;
import cn.zifangsky.service.AuthorizationService;
import cn.zifangsky.utils.DateUtils;
import cn.zifangsky.utils.JsonUtils;
import org.apache.commons.lang3.StringUtils;
import org.springframework.web.servlet.handler.HandlerInterceptorAdapter;

import javax.annotation.Resource;
import javax.servlet.http.HttpServletRequest;
import javax.servlet.http.HttpServletResponse;
import java.time.LocalDateTime;
import java.util.HashMap;
import java.util.Map;

/**
 * 用于校验Access Token是否为空以及Access Token是否已经失效
 *
 * @author zifangsky
 * @date 2018/8/22
 * @since 1.0.0
 */
public class AuthAccessTokenInterceptor extends HandlerInterceptorAdapter{
    @Resource(name = "authorizationServiceImpl")
    private AuthorizationService authorizationService;

    /**
     * 检查Access Token是否已经失效
     */
    @Override
    public boolean preHandle(HttpServletRequest request, HttpServletResponse response, Object handler) throws Exception {
        String accessToken = request.getParameter("access_token");

        if(StringUtils.isNoneBlank(accessToken)){
            //查询数据库中的Access Token
            AuthAccessToken authAccessToken = authorizationService.selectByAccessToken(accessToken);

            if(authAccessToken != null){
                Long savedExpiresAt = authAccessToken.getExpiresIn();
                //过期日期
                LocalDateTime expiresDateTime = DateUtils.ofEpochSecond(savedExpiresAt, null);
                //当前日期
                LocalDateTime nowDateTime = DateUtils.now();

                //如果Access Token已经失效,则返回错误提示
                return expiresDateTime.isAfter(nowDateTime) || this.generateErrorResponse(response, ErrorCodeEnum.EXPIRED_TOKEN);
            }else{
                return this.generateErrorResponse(response, ErrorCodeEnum.INVALID_GRANT);
            }
        }else{
            return this.generateErrorResponse(response, ErrorCodeEnum.INVALID_REQUEST);
        }
    }
    
    /**
     * 组装错误请求的返回
     */
    private boolean generateErrorResponse(HttpServletResponse response,ErrorCodeEnum errorCodeEnum) throws Exception {
        response.setCharacterEncoding("UTF-8");
        response.setHeader("Content-type", "application/json;charset=UTF-8");
        Map result = new HashMap<>(2);
        result.put("error", errorCodeEnum.getError());
        result.put("error_description",errorCodeEnum.getErrorDescription());

        response.getWriter().write(JsonUtils.toJson(result));
        return false;
    }

}

然后再根据这个Access Token被授予的访问范围返回相应的用户信息:

package cn.zifangsky.controller;

import cn.zifangsky.enums.ErrorCodeEnum;
import cn.zifangsky.model.AuthAccessToken;
import cn.zifangsky.model.User;
import cn.zifangsky.service.AuthorizationService;
import cn.zifangsky.service.UserService;
import cn.zifangsky.utils.JsonUtils;
import org.springframework.http.MediaType;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RestController;

import javax.annotation.Resource;
import javax.servlet.http.HttpServletRequest;
import java.util.HashMap;
import java.util.Map;

/**
 * 通过Access Token访问的API服务
 *
 * @author zifangsky
 * @date 2018/8/22
 * @since 1.0.0
 */
@RestController
@RequestMapping("/api")
public class ApiController {

    @Resource(name = "authorizationServiceImpl")
    private AuthorizationService authorizationService;

    @Resource(name = "userServiceImpl")
    private UserService userService;

    @RequestMapping(value = "/users/getInfo", produces = MediaType.APPLICATION_JSON_UTF8_VALUE)
    public String getUserInfo(HttpServletRequest request){
        String accessToken = request.getParameter("access_token");
        //查询数据库中的Access Token
        AuthAccessToken authAccessToken = authorizationService.selectByAccessToken(accessToken);

        if(authAccessToken != null){
            User user = userService.selectUserInfoByScope(authAccessToken.getUserId(), authAccessToken.getScope());
            return JsonUtils.toJson(user);
        }else{
            return this.generateErrorResponse(ErrorCodeEnum.INVALID_GRANT);
        }
    }

    /**
     * 组装错误请求的返回
     */
    private String generateErrorResponse(ErrorCodeEnum errorCodeEnum) {
        Map result = new HashMap<>(2);
        result.put("error", errorCodeEnum.getError());
        result.put("error_description",errorCodeEnum.getErrorDescription());

        return JsonUtils.toJson(result);
    }

}

调用的代码逻辑如下:

@Override
public User selectUserInfoByScope(Integer userId, String scope) {
	User user = userMapper.selectByPrimaryKey(userId);

	//如果是基础权限,则部分信息不返回
	if(ScopeEnum.BASIC.getCode().equalsIgnoreCase(scope)){
		user.setPassword(null);
		user.setCreateTime(null);
		user.setUpdateTime(null);
		user.setStatus(null);
	}

	return user;
}

三 接入OAuth2.0授权的第三方客户端的代码逻辑

这个Demo的第三方客户端的完整可用源码可以参考:https://gitee.com/zifangsky/OAuth2.0Demo/tree/master/ClientDemo

其实,对于接入的第三方客户端来说,后台的代码逻辑跟我上篇文章中接入百度OAuth服务的代码逻辑是差不多的。示例如下:

package cn.zifangsky.controller;

import cn.zifangsky.common.Constants;
import cn.zifangsky.model.AuthorizationResponse;
import cn.zifangsky.model.User;
import cn.zifangsky.utils.EncryptUtils;
import org.apache.commons.lang3.StringUtils;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.stereotype.Controller;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.client.RestTemplate;
import org.springframework.web.servlet.ModelAndView;

import javax.servlet.http.HttpServletRequest;
import javax.servlet.http.HttpSession;
import java.text.MessageFormat;

/**
 * 登录
 * @author zifangsky
 * @date 2018/7/9
 * @since 1.0.0
 */
@Controller
public class LoginController {

    @Autowired
    private RestTemplate restTemplate;

    @Value("${own.oauth2.client-id}")
    private String clientId;

    @Value("${own.oauth2.scope}")
    private String scope;

    @Value("${own.oauth2.client-secret}")
    private String clientSecret;

    @Value("${own.oauth2.user-authorization-uri}")
    private String authorizationUri;

    @Value("${own.oauth2.access-token-uri}")
    private String accessTokenUri;

    @Value("${own.oauth2.resource.userInfoUri}")
    private String userInfoUri;

    /**
     * 登录验证(实际登录调用认证服务器)
     * @author zifangsky
     * @date 2018/7/25 16:42
     * @since 1.0.0
     * @param request HttpServletRequest
     * @return org.springframework.web.servlet.ModelAndView
     */
    @RequestMapping("/login")
    public ModelAndView login(HttpServletRequest request){
        //当前系统登录成功之后的回调URL
        String redirectUrl = request.getParameter("redirectUrl");
        //当前系统请求认证服务器成功之后返回的Authorization Code
        String code = request.getParameter("code");

        //最后重定向的URL
        String resultUrl = "redirect:";
        HttpSession session = request.getSession();
        //当前请求路径
        String currentUrl = request.getRequestURL().toString();

        //code为空,则说明当前请求不是认证服务器的回调请求,则重定向URL到认证服务器登录
        if(StringUtils.isBlank(code)){
            //如果存在回调URL,则将这个URL添加到session
            if(StringUtils.isNoneBlank(redirectUrl)){
                session.setAttribute(Constants.SESSION_LOGIN_REDIRECT_URL,redirectUrl);
            }

            //生成随机的状态码,用于防止CSRF攻击
            String status = EncryptUtils.getRandomStr1(6);
            session.setAttribute(Constants.SESSION_AUTH_CODE_STATUS, status);
            //拼装请求Authorization Code的地址
            resultUrl += MessageFormat.format(authorizationUri,clientId,status,currentUrl);
        }else{
            //2. 通过Authorization Code获取Access Token
            AuthorizationResponse response = restTemplate.getForObject(accessTokenUri, AuthorizationResponse.class
                    ,clientId,clientSecret,code,currentUrl);

            //如果正常返回
            if(StringUtils.isNoneBlank(response.getAccess_token())){
                System.out.println(response);

                //2.1 将Access Token存到session
                session.setAttribute(Constants.SESSION_ACCESS_TOKEN,response.getAccess_token());

                //2.2 再次查询用户基础信息,并将用户ID存到session
                User user = restTemplate.getForObject(userInfoUri, User.class
                        ,response.getAccess_token());

                if(StringUtils.isNoneBlank(user.getUsername())){
                    session.setAttribute(Constants.SESSION_USER,user);
                }
            }

            //3. 从session中获取回调URL,并返回
            redirectUrl = (String) session.getAttribute(Constants.SESSION_LOGIN_REDIRECT_URL);
            session.removeAttribute("redirectUrl");
            if(StringUtils.isNoneBlank(redirectUrl)){
                resultUrl += redirectUrl;
            }else{
                resultUrl += "/user/userIndex";
            }
        }

        return new ModelAndView(resultUrl);
    }

}

需要注意的是,我这里添加了一个状态码,用于防止OAuth2.0授权登录过程中的CSRF攻击。因此,需要新添加一个拦截器,用于在请求完Authorization Code回调的时候校验这个状态码。相关代码如下:

package cn.zifangsky.interceptor;

import cn.zifangsky.common.Constants;
import cn.zifangsky.enums.ErrorCodeEnum;
import cn.zifangsky.utils.JsonUtils;
import org.apache.commons.lang3.StringUtils;
import org.springframework.web.servlet.handler.HandlerInterceptorAdapter;

import javax.servlet.http.HttpServletRequest;
import javax.servlet.http.HttpServletResponse;
import javax.servlet.http.HttpSession;
import java.util.HashMap;
import java.util.Map;

/**
 * 用于校验OAuth2.0登录中的状态码
 *
 * @author zifangsky
 * @date 2018/8/23
 * @since 1.0.0
 */
public class AuthInterceptor extends HandlerInterceptorAdapter{

    @Override
    public boolean preHandle(HttpServletRequest request, HttpServletResponse response, Object handler) throws Exception {
        HttpSession session = request.getSession();

        //当前系统请求认证服务器成功之后返回的Authorization Code
        String code = request.getParameter("code");
        //原样返回的状态码
        String resultStatus = request.getParameter("status");

        //code不为空,则说明当前请求是从认证服务器返回的回调请求
        if(StringUtils.isNoneBlank(code)){
            //从session获取保存的状态码
            String savedStatus = (String) session.getAttribute(Constants.SESSION_AUTH_CODE_STATUS);
            //1. 校验状态码是否匹配
            if(savedStatus != null && resultStatus != null && savedStatus.equals(resultStatus)){
                return true;
            }else{
                response.setCharacterEncoding("UTF-8");
                response.setHeader("Content-type", "application/json;charset=UTF-8");
                Map result = new HashMap<>(2);
                result.put("error", ErrorCodeEnum.INVALID_STATUS.getError());
                result.put("error_description",ErrorCodeEnum.INVALID_STATUS.getErrorDescription());

                response.getWriter().write(JsonUtils.toJson(result));
                return false;
            }
        }else{
            return true;
        }
    }
}

另外,实际上面代码中使用到的一些配置就是我们OAuth2.0服务端的接口地址:

own.oauth2.client-id=7Ugj6XWmTDpyYp8M8njG3hqx
own.oauth2.scope=super
own.oauth2.client-secret=tur2rlFfywR9OOP3fB5ZbsLTnNuNabI3
own.oauth2.user-authorization-uri=http://10.0.5.22:7000/oauth2.0/authorize?client_id={0}&response_type=code&scope=super&&status={1}&redirect_uri={2}
own.oauth2.access-token-uri=http://10.0.5.22:7000/oauth2.0/token?client_id={1}&client_secret={2}&grant_type=authorization_code&code={3}&redirect_uri={4}

own.oauth2.resource.userInfoUri=http://10.0.5.22:7000/api/users/getInfo?access_token={1}

特别提示:在测试代码的时候,最好将授权服务端和客户端分别运行于两个不同服务器上面,不然域名都是localhost会被浏览器判断为同一个网站。

 

 

转载自:OAuth2.0协议入门(二):OAuth2.0授权服务端从设计到实现 - zifangsky的个人博客

 

 

 

 

 

 

 

 

 

 

 

你可能感兴趣的:(单点登录)