oauth2使用心得-----基本概念以及认证服务器搭建

应用场景

我们假设你有一个“云笔记”产品,并提供了“云笔记服务”和“云相册服务”,此时用户需要在不同的设备(PC、Android、iPhone、TV、Watch)上去访问这些“资源”(笔记,图片)

那么用户如何才能访问属于自己的那部分资源呢?此时传统的做法就是提供自己的账号和密码给我们的“云笔记”,登录成功后就可以获取资源了。但这样的做法会有以下几个问题:

  • “云笔记服务”和“云相册服务”会分别部署,难道我们要分别登录吗?
  • 如果有第三方应用程序想要接入我们的“云笔记”,难道需要用户提供账号和密码给第三方应用程序,让他记录后再访问我们的资源吗?
  • 用户如何限制第三方应用程序在我们“云笔记”的授权范围和使用期限?难道把所有资料都永久暴露给它吗?
  • 如果用户修改了密码收回了权限,那么所有第三方应用程序会全部失效。
  • 只要有一个接入的第三方应用程序遭到破解,那么用户的密码就会泄露,后果不堪设想。

为了解决如上问题,oAuth 应用而生。

名词解释

  • 第三方应用程序(Third-party application): 又称之为客户端(client),比如上节中提到的设备(PC、Android、iPhone、TV、Watch),我们会在这些设备中安装我们自己研发的 APP。又比如我们的产品想要使用 QQ、微信等第三方登录。对我们的产品来说,QQ、微信登录是第三方登录系统。我们又需要第三方登录系统的资源(头像、昵称等)。对于 QQ、微信等系统我们又是第三方应用程序。
  • HTTP 服务提供商(HTTP service): 我们的云笔记产品以及 QQ、微信等都可以称之为“服务提供商”。
  • 资源所有者(Resource Owner): 又称之为用户(user)。
  • 用户代理(User Agent): 比如浏览器,代替用户去访问这些资源。
  • 认证服务器(Authorization server): 即服务提供商专门用来处理认证的服务器,简单点说就是登录功能(验证用户的账号密码是否正确以及分配相应的权限)
  • 资源服务器(Resource server): 即服务提供商存放用户生成的资源的服务器。它与认证服务器,可以是同一台服务器,也可以是不同的服务器。简单点说就是资源的访问入口,比如上节中提到的“云笔记服务”和“云相册服务”都可以称之为资源服务器。

交互过程

举个例子来说吧,你使用qq号登录知乎,肯定不能告诉知乎你的密码,那么怎么做呢?知乎返回授权页,用户授权知乎,然后知乎向qq申请令牌,知乎通过令牌去访问用户qq相关的资源,这样用户的密码不会向知乎暴露,知乎也访问了用户相关的qq信息。

客户端授权模式

客户端必须得到用户的授权(authorization grant),才能获得令牌(access token)。oAuth 2.0 定义了四种授权方式。

  • implicit:简化模式,不推荐使用
  • authorization code:授权码模式
  • resource owner password credentials:密码模式
  • client credentials:客户端模式

1、简化模式

简化模式适用于纯静态页面应用。所谓纯静态页面应用,也就是应用没有在服务器上执行代码的权限(通常是把代码托管在别人的服务器上),只有前端 JS 代码的控制权。

这种场景下,应用是没有持久化存储的能力的。因此,按照 oAuth2.0 的规定,这种应用是拿不到 Refresh Token 的。其整个授权流程如下:

2、授权码模式

授权码模式适用于有自己的服务器的应用,它是一个一次性的临时凭证,用来换取 access_token 和 refresh_token。认证服务器提供了一个类似这样的接口: 

https://www.baidu.com/exchange?code=&client_id=&client_secret=

需要传入 codeclient_id 以及 client_secret。验证通过后,返回 access_token 和 refresh_token。一旦换取成功,code 立即作废,不能再使用第二次。流程图如下:

这个 code 的作用是保护 token 的安全性。上一节说到,简单模式下,token 是不安全的。这是因为在第 4 步当中直接把 token 返回给应用。而这一步容易被拦截、窃听。引入了 code 之后,即使攻击者能够窃取到 code,但是由于他无法获得应用保存在服务器的 client_secret,因此也无法通过 code 换取 token。而第 5 步,为什么不容易被拦截、窃听呢?这是因为,首先,这是一个从服务器到服务器的访问,黑客比较难捕捉到;其次,这个请求通常要求是 https 的实现。即使能窃听到数据包也无法解析出内容。

3、密码模式-----本文后续基于此种方式 

密码模式中,用户向客户端提供自己的用户名和密码。客户端使用这些信息,向 "服务商提供商" 索要授权。在这种模式中,用户必须把自己的密码给客户端,但是客户端不得储存密码。这通常用在用户对客户端高度信任的情况下,比如客户端是操作系统的一部分。

一个典型的例子是同一个企业内部的不同产品要使用本企业的 oAuth2.0 体系。在有些情况下,产品希望能够定制化授权页面。由于是同个企业,不需要向用户展示“xxx将获取以下权限”等字样并询问用户的授权意向,而只需进行用户的身份认证即可。这个时候,由具体的产品团队开发定制化的授权界面,接收用户输入账号密码,并直接传递给鉴权服务器进行授权即可。

4、客户端模式

如果信任关系再进一步,或者调用者是一个后端的模块,没有用户界面的时候,可以使用客户端模式。鉴权服务器直接对客户端进行身份验证,验证通过后,返回 token。

代码模块

表结构 

oauth_client_details-----客户端相关数据

CREATE TABLE `oauth_client_details` (
  `client_id` varchar(128) NOT NULL,
  `resource_ids` varchar(256) DEFAULT NULL,
  `client_secret` varchar(256) DEFAULT NULL,
  `scope` varchar(256) DEFAULT NULL,
  `authorized_grant_types` varchar(256) DEFAULT NULL,
  `web_server_redirect_uri` varchar(256) DEFAULT NULL,
  `authorities` varchar(256) DEFAULT NULL,
  `access_token_validity` int(11) DEFAULT NULL,
  `refresh_token_validity` int(11) DEFAULT NULL,
  `additional_information` varchar(4096) DEFAULT NULL,
  `autoapprove` varchar(256) DEFAULT NULL,
  PRIMARY KEY (`client_id`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8;

client_secret:一定要为BycrPassWord后的串,因为oauth2会拿明文密码通过bycr加密后与数据库中数据进行比对。

authorized_grant_types:授权方式,本文以password为例

access_token_validity:token有效期

后台代码

1、pom.xml 

<?xml version="1.0" encoding="UTF-8"?>
<project xmlns="http://maven.apache.org/POM/4.0.0" xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
         xsi:schemaLocation="http://maven.apache.org/POM/4.0.0 http://maven.apache.org/xsd/maven-4.0.0.xsd">
    <modelVersion>4.0.0</modelVersion>
    <parent>
        <groupId>org.springframework.boot</groupId>
        <artifactId>spring-boot-starter-parent</artifactId>
        <version>2.1.6.RELEASE</version>
        <relativePath/> <!-- lookup parent from repository -->
    </parent>
    <groupId>com.ty</groupId>
    <artifactId>auth</artifactId>
    <version>0.0.1-SNAPSHOT</version>
    <name>auth</name>
    <description>Demo project for Spring Boot</description>

    <properties>
        <java.version>1.8</java.version>
    </properties>

    <dependencies>
        <dependency>
            <groupId>org.springframework.boot</groupId>
            <artifactId>spring-boot-starter</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-test</artifactId>
            <scope>test</scope>
        </dependency>

        <dependency>
            <groupId>org.springframework.cloud</groupId>
            <artifactId>spring-cloud-starter-oauth2</artifactId>
            <version>2.1.3.RELEASE</version>
        </dependency>

        <dependency>
            <groupId>mysql</groupId>
            <artifactId>mysql-connector-java</artifactId>
            <version>8.0.16</version>
        </dependency>

        <dependency>
            <groupId>org.springframework.boot</groupId>
            <artifactId>spring-boot-starter-jdbc</artifactId>
            <version>2.1.6.RELEASE</version>
        </dependency>

        <dependency>
            <groupId>org.springframework.boot</groupId>
            <artifactId>spring-boot-starter-data-redis</artifactId>
            <version>2.1.6.RELEASE</version>
        </dependency>
    </dependencies>

    <build>
        <plugins>
            <plugin>
                <groupId>org.springframework.boot</groupId>
                <artifactId>spring-boot-maven-plugin</artifactId>
            </plugin>
        </plugins>
    </build>

</project>

2、application.yml

spring:
  application:
    name: auth-server
  security:
    user:
      # 账号
      name: taoyong
      # 密码
      password: 123456
  datasource:
    url: jdbc:mysql://localhost:3306/mysql?useUnicode=true&characterEncoding=utf8&serverTimezone=GMT%2B8&useSSL=false
    driverClassName: com.mysql.jdbc.Driver
    username: alimayun
    password: ty123456
  redis:
    host: 127.0.0.1
    port: 6379
    password:
server:
  port: 8080

3、AuthorizationServerConfiguration

package com.ty.auth.config.auth;

import com.ty.auth.exception.handler.CustomWebResponseExceptionTranslator;
import com.ty.auth.store.CustomRedisToken;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.data.redis.connection.RedisConnectionFactory;
import org.springframework.security.authentication.AuthenticationManager;
import org.springframework.security.oauth2.config.annotation.configurers.ClientDetailsServiceConfigurer;
import org.springframework.security.oauth2.config.annotation.web.configuration.AuthorizationServerConfigurerAdapter;
import org.springframework.security.oauth2.config.annotation.web.configuration.EnableAuthorizationServer;
import org.springframework.security.oauth2.config.annotation.web.configurers.AuthorizationServerEndpointsConfigurer;
import org.springframework.security.oauth2.provider.ClientDetailsService;
import org.springframework.security.oauth2.provider.client.JdbcClientDetailsService;
import org.springframework.security.oauth2.provider.error.WebResponseExceptionTranslator;
import org.springframework.security.oauth2.provider.token.TokenStore;
import org.springframework.security.oauth2.provider.token.store.JdbcTokenStore;
import org.springframework.security.oauth2.provider.token.store.redis.RedisTokenStore;

import javax.sql.DataSource;

@Configuration
@EnableAuthorizationServer
public class AuthorizationServerConfiguration extends AuthorizationServerConfigurerAdapter {

    @Autowired
    public DataSource dataSource;

    //使用password模式必须要此bean
    @Autowired
    private AuthenticationManager authenticationManager;

    @Autowired
    private RedisConnectionFactory redisConnectionFactory;

    @Bean
    public TokenStore tokenStore() {
        // 基于redis实现,令牌保存到redis,并且可以实现redis刷新的功能
        return new CustomRedisToken(redisConnectionFactory, jdbcClientDetails());
    }

    @Bean
    public ClientDetailsService jdbcClientDetails() {
        // 基于 JDBC 实现,需要事先在数据库配置客户端信息
        return new JdbcClientDetailsService(dataSource);
    }

    @Override
    public void configure(AuthorizationServerEndpointsConfigurer endpoints) throws Exception {
        // 设置令牌
        endpoints.tokenStore(tokenStore());
        endpoints.authenticationManager(authenticationManager);
        endpoints.exceptionTranslator(new CustomWebResponseExceptionTranslator());
    }

    @Override
    public void configure(ClientDetailsServiceConfigurer clients) throws Exception {
        // 读取客户端配置
        clients.withClientDetails(jdbcClientDetails());
    }
}

4、WebSecurityConfiguration

package com.ty.auth.config.security;

import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.security.authentication.AuthenticationManager;
import org.springframework.security.config.annotation.authentication.builders.AuthenticationManagerBuilder;
import org.springframework.security.config.annotation.method.configuration.EnableGlobalMethodSecurity;
import org.springframework.security.config.annotation.web.configuration.EnableWebSecurity;
import org.springframework.security.config.annotation.web.configuration.WebSecurityConfigurerAdapter;
import org.springframework.security.core.userdetails.UserDetails;
import org.springframework.security.core.userdetails.UserDetailsService;
import org.springframework.security.crypto.bcrypt.BCryptPasswordEncoder;

@Configuration
@EnableWebSecurity
@EnableGlobalMethodSecurity(prePostEnabled = true, securedEnabled = true, jsr250Enabled = true)
public class WebSecurityConfiguration extends WebSecurityConfigurerAdapter {

    @Bean
    public BCryptPasswordEncoder passwordEncoder() {
        // 设置默认的加密方式
        return new BCryptPasswordEncoder();
    }

    //password模式必须需要
    @Override
    @Bean
    public AuthenticationManager authenticationManagerBean() throws Exception {

        return super.authenticationManagerBean();
    }

    @Override
    protected void configure(AuthenticationManagerBuilder auth) throws Exception {
        //这里可以在数据库中做。主要就是实现UserDetailsService接口,自定义loadUserByUsername方法
        //auth.userDetailsService(xxx)
        auth.inMemoryAuthentication()
                // 在内存中创建用户并为密码加密
                .withUser("alimayun").password(passwordEncoder().encode("123456")).roles("USER")
                .and()
                .withUser("ty").password(passwordEncoder().encode("123456")).roles("ADMIN");

    }
}

5、ResourceServerConfigurer

package com.ty.auth.config.resource;

import com.ty.auth.exception.handler.CustomAccessDeniedHandler;
import com.ty.auth.exception.handler.MyAuthenticationEntryPoint;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.context.annotation.Configuration;
import org.springframework.security.oauth2.config.annotation.web.configuration.EnableResourceServer;
import org.springframework.security.oauth2.config.annotation.web.configuration.ResourceServerConfigurerAdapter;
import org.springframework.security.oauth2.config.annotation.web.configurers.ResourceServerSecurityConfigurer;
import org.springframework.security.oauth2.provider.token.TokenStore;

//为了方便,直接把认证服务器也当做是一个资源服务器
@Configuration
@EnableResourceServer
public class ResourceServerConfigurer extends ResourceServerConfigurerAdapter {

    @Autowired
    private TokenStore tokenStore;

    @Override
    public void configure(ResourceServerSecurityConfigurer resources) throws Exception {
        resources.tokenStore(tokenStore).authenticationEntryPoint(new MyAuthenticationEntryPoint())
                .accessDeniedHandler(new CustomAccessDeniedHandler());
    }
}

6、异常类

CustomAccessDeniedHandler

package com.ty.auth.exception.handler;

import com.fasterxml.jackson.databind.ObjectMapper;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.security.access.AccessDeniedException;
import org.springframework.security.web.access.AccessDeniedHandler;
import org.springframework.stereotype.Service;


import org.springframework.security.web.access.AccessDeniedHandler;

import javax.servlet.ServletException;
import javax.servlet.http.HttpServletRequest;
import javax.servlet.http.HttpServletResponse;
import java.io.IOException;
import java.util.Date;
import java.util.HashMap;
import java.util.Map;

public class CustomAccessDeniedHandler implements AccessDeniedHandler {
    @Autowired
    private ObjectMapper objectMapper;

    //权限不足异常处理类
    @Override
    public void handle(HttpServletRequest request, HttpServletResponse response, AccessDeniedException accessDeniedException) throws IOException, ServletException {
        response.setContentType("application/json;charset=UTF-8");
        Map<String, Object> map = new HashMap<>();
        map.put("resultCode", "400");
        map.put("resultMsg", accessDeniedException.getMessage());
        map.put("path", request.getServletPath());
        map.put("timestamp", String.valueOf(new Date().getTime()));
        response.setContentType("application/json");
        response.setStatus(HttpServletResponse.SC_UNAUTHORIZED);
        response.getWriter().write(objectMapper.writeValueAsString(map));
    }
}

MyAuthenticationEntryPoint

package com.ty.auth.exception.handler;

import com.fasterxml.jackson.databind.ObjectMapper;
import org.springframework.security.core.AuthenticationException;
import org.springframework.security.web.AuthenticationEntryPoint;
import org.springframework.stereotype.Service;

import javax.servlet.ServletException;
import javax.servlet.http.HttpServletRequest;
import javax.servlet.http.HttpServletResponse;
import java.io.IOException;
import java.util.Date;
import java.util.HashMap;
import java.util.Map;

public class MyAuthenticationEntryPoint implements AuthenticationEntryPoint {

    //认证无效,例如token无效等等
    @Override
    public void commence(HttpServletRequest request, HttpServletResponse response, AuthenticationException authException)
            throws IOException, ServletException {
        Map<String, Object> map = new HashMap<>();
        //通过自定义异常可以按照自己的意愿去返回这些异常信息,因为大部分企业级应用都是前后分离,对前端友好很重要!
        map.put("resultCode", "401");
        map.put("resultMsg", authException.getMessage());
        map.put("path", request.getServletPath());
        map.put("timestamp", String.valueOf(new Date().getTime()));
        response.setContentType("application/json");
        response.setStatus(HttpServletResponse.SC_UNAUTHORIZED);
        try {
        ObjectMapper mapper = new ObjectMapper();
        mapper.writeValue(response.getOutputStream(), map);
        } catch (Exception e) {
        throw new ServletException();
        }
    }
}

CustomWebResponseExceptionTranslator

package com.ty.auth.exception.handler;

import org.springframework.http.ResponseEntity;
import org.springframework.security.authentication.InternalAuthenticationServiceException;
import org.springframework.security.oauth2.common.exceptions.InvalidGrantException;
import org.springframework.security.oauth2.common.exceptions.InvalidTokenException;
import org.springframework.security.oauth2.common.exceptions.OAuth2Exception;
import org.springframework.security.oauth2.provider.error.WebResponseExceptionTranslator;
import org.springframework.stereotype.Service;

import javax.xml.transform.Result;
import java.util.HashMap;
import java.util.Map;

//这是获取token阶段出现异常部分
public class CustomWebResponseExceptionTranslator implements WebResponseExceptionTranslator {
    public ResponseEntity translate(Exception e) throws Exception {
        if (e instanceof InternalAuthenticationServiceException) {
            Map<String, Object> result = new HashMap<>();
            result.put("resultCode", "401");
            result.put("resultMsg", "用户不存在");
            return ResponseEntity.ok(result);
        }

        if (e instanceof InvalidGrantException) {
            Map<String, Object> result = new HashMap<>();
            result.put("resultCode", "401");
            result.put("resultMsg", "密码错误");
            return ResponseEntity.ok(result);
        }

        if (e instanceof InvalidTokenException) {
            Map<String, Object> result = new HashMap<>();
            result.put("resultCode", "401");
            result.put("resultMsg", "token未识别");
            return ResponseEntity.ok(result);
        }
        throw e;
    }

}

7、CustomRedisToken

package com.ty.auth.store;

import org.springframework.data.redis.connection.RedisConnectionFactory;
import org.springframework.security.oauth2.common.DefaultOAuth2AccessToken;
import org.springframework.security.oauth2.common.OAuth2AccessToken;
import org.springframework.security.oauth2.provider.ClientDetails;
import org.springframework.security.oauth2.provider.ClientDetailsService;
import org.springframework.security.oauth2.provider.OAuth2Authentication;
import org.springframework.security.oauth2.provider.OAuth2Request;
import org.springframework.security.oauth2.provider.token.store.redis.RedisTokenStore;

import java.util.Date;

public class CustomRedisToken extends RedisTokenStore {
    private ClientDetailsService clientDetailsService;

    public CustomRedisToken(RedisConnectionFactory connectionFactory, ClientDetailsService clientDetailsService) {
        super(connectionFactory);
        this.clientDetailsService = clientDetailsService;
    }

    //为什么需要刷新token的时间,比如默认1个小时,客户一直在操作,到了1个小时,让其登录,这种体验很差,应该是客户啥时候不请求服务器了,隔多长时间
    //认为其token失效
    // 其实这块可以看下源码,在客户端请求过来的时候,首先到达的是org.springframework.security.oauth2.provider.authentication.
    // OAuth2AuthenticationProcessingFilter。然后在请求校验完token有效之后,以当前时间刷新token,具体时间配置在数据库中~~~
    @Override
    public OAuth2Authentication readAuthentication(OAuth2AccessToken token) {
        OAuth2Authentication result = readAuthentication(token.getValue());
        if (result != null) {
            // 如果token没有失效  更新AccessToken过期时间
            DefaultOAuth2AccessToken oAuth2AccessToken = (DefaultOAuth2AccessToken) token;

            //重新设置过期时间
            int validitySeconds = getAccessTokenValiditySeconds(result.getOAuth2Request());
            if (validitySeconds > 0) {
                oAuth2AccessToken.setExpiration(new Date(System.currentTimeMillis() + (validitySeconds * 1000L)));
            }

            //将重新设置过的过期时间重新存入redis, 此时会覆盖redis中原本的过期时间
            storeAccessToken(token, result);
        }
        return result;
    }

    protected int getAccessTokenValiditySeconds(OAuth2Request clientAuth) {
        if (clientDetailsService != null) {
            ClientDetails client = clientDetailsService.loadClientByClientId(clientAuth.getClientId());
            Integer validity = client.getAccessTokenValiditySeconds();
            if (validity != null) {
                return validity;
            }
        }
        // default 12 hours.
        int accessTokenValiditySeconds = 60 * 60 * 12;
        return accessTokenValiditySeconds;
    }
}

测试

首先编写一个测试controller

package com.ty.auth.controller;

import org.springframework.web.bind.annotation.PostMapping;
import org.springframework.web.bind.annotation.RestController;

@RestController
public class TestController {

    @PostMapping("/hello")
    public String hello() {
        return "hello";
    }
}

1、打开postman,直接访问

提示401,没有认证

2、请求token

点击preview request,变成下面这样:

3、拿着token值访问/hello

这就是一个简单的认证过程。token我设置默认是1800s过期,随着我不断请求,token有效期也会自动顺延

1733秒过期,过一会儿我再访问/hello,刷新token

原文地址:https://www.cnblogs.com/alimayun/p/11192326.html