开发中常用的鉴权方案

1 鉴权定义

鉴权(authentication)是指验证用户是否拥有访问系统的权利。传统的鉴权是通过密码来验证的,这种方法的前提是,每个获得密码的用户都已经被授予了对应的权限。在建立用户时,就为此用户分配一个密码,用户的密码可以由管理员指定,也可以由用户自行申请。这种方法弱点十分明显:一旦密码被偷或用户遗失密码,情况就会十分麻烦,需要管理员对用户密码进行重新修改,而修改密码之前还要人工验证用户的合法身份。

为了克服这种鉴权方式的缺点,需要一个更加可靠的鉴权方式。目前的主流鉴权方式是利用认证授权来验证数字签名的正确与否。

逻辑上,授权发生在鉴权之后,而实际上,这两者常常是一个过程。

说白了,鉴权就是验证你身份的一个过程,看看你是否有相应的权利做相应的操作。

2 常用的鉴权方式

2.1 HTTP Basic Authentication

2.1.1 基本概念及流程

这种授权方式是浏览器遵守HTTP协议实现的基本授权方式。HTTP协议进行通信的过程中,HTTP协议已经定义了基本的认证方式,验证是否允许客户端的用户访问HTTP服务器的资源。

  • 认证过程:
  1. 客户端向服务器请求数据,请求的内容是一个网页或ajax异步请求,此时,假如,客户端尚未被验证:

客户端请求信息:

Get /index.html HTTP/1.0
Host:www.google.com
  1. 服务器向客户端发送验证请求代码401,(WWW-Authenticate: Basic realm=”google.com”这句话是关键,如果没有客户端,则不会弹出用户名和密码输入界面)服务器返回的数据大抵如下:

服务器响应信息:

HTTP/1.0 401 Unauthorised
Server: SokEvo/1.0
WWW-Authenticate: Basic realm=”google.com”
Content-Type: text/html
Content-Length: xxx
  1. 当符合http1.0或1.1规范的客户端(如IE,FIREFOX)收到401返回值时,将自动弹出一个登录窗口,要求用户输入用户名和密码。
  2. 用户输入用户名和密码后,将用户名及密码以BASE64加密方式加密,并将密文放入前一条请求信息中,则客户端发送的第一条请求信息则变成如下内容:
Get /index.html HTTP/1.0
Host:www.google.com
Authorization: Basic d2FuZzp3YW5n

注:d2FuZzp3YW5n表示加密后的用户名及密码(用户名:密码 然后通过base64加密,加密过程是浏览器默
认的行为,不需要我们人为加密,我们只需要输入用户名密码即可)
  1. 服务器收到上述请求信息后,将Authorization字段后的用户信息取出、解密,将解密后的用户名及密码与用户数据库进行比较验证,如用户名及密码正确,服务器则根据请求,将所请求资源发送给客户端。

客户端未未认证的时候,会弹出用户名密码输入框,这个时候请求时属于pending状态,这个时候其实服务当用户输入用户名密码的时候客户端会再次发送带Authentication头的请求。

总结:

"总的来说,大致可以分为概括为以下步骤":
1. 客户端通过浏览器向服务器发送请求
2. 已经认证:如果客户端已经认证了,有`Authorization`字段,则可以直接访问到资源
3. 没有认证:页面弹出提示框,用户输入用户名,密码,然后将数据(加密后的)发送给服务器
4. 如果用户名密码正确,则可以访问资源,并且会将认证信息存放在
5. 如果错误,则不能访问
2.1.2 代码演示

创建一个SpringBoot项目

定义对应的controller:

@RestController
public class HTTPAuthServletController {

    private static final ObjectMapper objectMapper = new ObjectMapper();

    @GetMapping(path = "/login")
    public void login(HttpServletRequest request, HttpServletResponse response) throws IOException {
        request.setCharacterEncoding("UTF-8");
        String authorization = request.getHeader("Authorization");
        System.out.println("Authorization: " + authorization);

        //判断之前是否有认证【或者认证是否过期】
        if (authorization == null) {
            //设置HTTP认证状态为:未认证
            response.setStatus(HttpStatus.UNAUTHORIZED.value());
            response.setHeader("WWW-Authenticate", "Basic realm=\"Realm\"");
        } else {
            String credentials = authorization.substring("Basic ".length());
            byte[] decodedCredentials = Base64Utils.decode(credentials.getBytes("UTF-8"));
            //根据用户输入的生成对应的字符串
            String str = new String(decodedCredentials);
            //正确的用户名与密码【用户名:user     密码:123】
            String tar = new String("user:123");
            if(tar.equals(str)) {
                //用户名密码正确,验证成功
                System.out.println("Decoded Credentials: " + new String(decodedCredentials));

                response.setStatus(HttpStatus.OK.value());
                response.setCharacterEncoding("UTF-8");
                response.setContentType("application/json");

                Map<String, Object> result = new HashMap<>();
                result.put("message", HttpStatus.OK.name());
                result.put("ip", request.getRemoteAddr());
                result.put("credentials", new String(decodedCredentials));

                PrintWriter writer = response.getWriter();
                writer.write(objectMapper.writeValueAsString(result));
                writer.flush();
                writer.close();
            } else {
                PrintWriter writer = response.getWriter();
                writer.write("authentication fail...");
            }

        }
    }
}

启动项目,根据对应路径访问资源:

①首次访问

查询鉴权架构 鉴权模式_服务器


②输入正确的用户名密码后【用户名:user 密码:123】:

查询鉴权架构 鉴权模式_鉴权_02

对应的请求体与响应体:

查询鉴权架构 鉴权模式_查询鉴权架构_03

③这个时候,我们不关闭浏览器,在新开一个窗口,访问同样的资源,可以发现不用登录

此时的请求头中已经多了我们的认证信息

查询鉴权架构 鉴权模式_服务器_04

如果输入错误的用户名或密码:

查询鉴权架构 鉴权模式_服务器_05

2.2 session - cookie

2.2.1 基本概念及流程

HTTP Cookie(也叫Web Cookie或浏览器Cookie)是服务器发送到用户浏览器并保存在本地的一小块数据,它会在浏览器下次向同一服务器再发起请求时被携带并发送到服务器上。通常,它用于告知服务端两个请求是否来自同一浏览器,如保持用户的登录状态。Cookie使基于无状态的HTTP协议记录稳定的状态信息成为了可能。

Cookie主要用于以下三个方面:

  • 会话状态管理(如:用户登录状态、购物车、游戏分数或其他需要记录的信息)
  • 个性化设置(如用户自定义设置、主题等)
  • 浏览器行为跟踪(如跟踪分析用户行为等)

认证过程:

  1. 服务器在接受客户端首次访问时在服务器端创建seesion,然后保存seesion(我们可以将seesion保存在内存中,也可以保存在redis中,推荐使用后者),然后给这个session生成一个唯一的标识字符串,然后在响应头中种下这个唯一标识字符串。
  2. 签名。这一步只是对sid进行加密处理,服务端会根据这个secret密钥进行解密。(非必需步骤)
  3. 浏览器中收到请求响应的时候会解析响应头,然后将sid保存在本地cookie中,浏览器在下次http请求de 请求头中会带上该域名下的cookie信息。
  4. 服务器在接受客户端请求时会去解析请求头cookie中的sid,然后根据这个sid去找服务器端保存的该客户端的session,然后判断该请求是否合法。
  5. 一旦用户登出,服务端和客户端同时销毁该会话在后续请求中,服务器会根据数据库验证会话id,如果验证通过,则继续处理;
拓展:cookie - session为什么可以实现鉴权

为什么cookie - session可以实现鉴权呢?

首先,HTTP是无状态协议,无法分辨请求是谁发起的,需要浏览器自己解决这个问题,不然有些情况下即使是打开同一个网站的不同页面也都要重新登录。而Cookie、Session和Token就是为了解决这个问题而提出来的两个机制。

用户通过浏览器登录一个网站,在该浏览器内打开网站其他页面时,不需要重新登录。而HTTP是无状态的协议,那么网站后端是如何判断用户已经登陆了呢?不同的网站,判断用户登录状态的方法都不一样。有的网站是通过session来验证用户的登录状态,有的网站是通过token来验证用户的登录状态,也有的网站是通过其他的方式来判断。

  • Cookie:cookie是服务器发送给客户端浏览器用于验证某一会话信息的数据,cookie中有很多字段。不同网站Cookie中字段是不一样的,是由服务器端设置的。Cookie中常放入session_id 或者 token 用来验证会话的登录状态。

为什么Cookie可以验证登录状态?因为Cookie中存放了session_id或者token值

  • Cookie的分类:
  • Session Cookie:key - value的形式,过期时间可以设置,如果不设置,浏览器关闭就消失了,存储在内存当中,否则就按设置的过期时间存储在硬盘上,过期后自动清除。
  • Permenent Cookie:Cookie主要内容包括:名字,值,过期时间,路径和域等。

Session Cookie: 我们打开一个浏览器访问某个网站,该网站服务器就会返回一个Session Cookie,当我们访问该网站下其他页面时,用该Cookie验证我们的身份。所以,我们不需要每个页面都登录。但是,当我们关闭浏览器重新访问该网站时,需要重新登录获取浏览器返回的Cookie。Session Cookie在访问一个网站的过程中,一般是不变化的,有时也会变化,比如,切换不同的权限时,Cookie值会变化。

查询鉴权架构 鉴权模式_JWT_06

在整个会话过程中,cookie主要的值是不变化的,某些值会变化。如果涉及到不同等级之间的用户的话,可能会发生变化。

Permenent Cookie: 是保存在浏览器客户端上存储用户登录信息的数据,Permenent Cookie是由服务器端生成,然后发送给User-Agent(一般是浏览器),浏览器会将Cookie保存到某个目录下的文本文件内,下次请求同一网站时就发送该Cookie给服务器(前提是浏览器设置为启用cookie,大部分浏览器默认都是开启了cookie)。

与之对应的还有token认证:

session与token认证区别:
- session:服务器端生成session数据,并返回给客户端一个session_id,客户端将session_id保存在
  cookie中。客户端访问的时候携带session_id,服务器收到请求后,查看数据库或内存中是否有与之对应
 的session数据。【服务器需要存储session信息,客户端需要存储session_id(如,java的JSESSIONDI)】

- token:服务端无状态的认证方式。服务器端不存放token数据。用户验证后,服务端产生一个token发给客户
  端,客户端可以放到cookie或localStorage中,每次请求时在Header中带上token,服务端收到token,
  通过验证后即可确认身份。【加密解密】

详细链接:

2.2.2 代码演示

大体思路:用户访问页面,如果用户已经认证过了,就直接可以访问资源,如果没有,则需要先进行登录

①导入依赖:thymeleaf

<!--模板引擎thymeleaf-->
<dependency>
    <groupId>org.springframework.boot</groupId>
    <artifactId>spring-boot-starter-thymeleaf</artifactId>
</dependency>

<dependency>
    <groupId>org.projectlombok</groupId>
    <artifactId>lombok</artifactId>
</dependency>
<dependency>
    <groupId>org.springframework.boot</groupId>
    <artifactId>spring-boot-starter-web</artifactId>
</dependency>

②页面
登录页面login.html

<!DOCTYPE html>
<html lang="en" xmlns:th="http://www.thymeleaf.org">
<head>
    <meta charset="UTF-8">
    <title>Title</title>
</head>
<body>
    <form action="/login" method="post">
        username:<input type="text" name="username">
        password:<input type="password" name="password">
        <input type="submit" value="提交">
    </form>

</body>
</html>

访问成功页面(获取资源页面)success.html

<!DOCTYPE html>
<html lang="en" xmlns:th="http://www.thymeleaf.org">
<head>
    <meta charset="UTF-8">
    <title>Title</title>
</head>
<body>
<h1>success...</h1>
</body>
</html>

访问失败页面:

<!DOCTYPE html>
<html lang="en" xmlns:th="http://www.thymeleaf.org">
<head>
    <meta charset="UTF-8">
    <title>Title</title>
</head>
<body>
username or password is wrong...
</body>
</html>

③配置文件

# 应用服务 WEB 访问端口
server:
  port: 8080
spring:
  application:
    name: demo
#   配置视图前后缀
  thymeleaf:
    prefix: classpath:/templates/
    suffix: .html

④实体类

@Data
public class User {

    private String username;
    private String password;
    private int age;
    private String address;

}

⑤controller层

@Controller
public class TestSessionAndCookieController {

    @GetMapping("/testSessionAndCookie")
    public String testSessionAndCookie(HttpServletRequest request){
        HttpSession session = request.getSession();
//        Object jessionid = session.getAttribute("JESSIONID");
        Object user = request.getSession().getAttribute("user");
        if(user == null){
            return "login";
        }
        Cookie[] cookies = request.getCookies();
        for(Cookie cookie : cookies){
            System.out.println("name: " + cookie.getName());
            System.out.println("value: " + cookie.getValue());
        }
        return "success";
    }

    @PostMapping("/login")
    public String login(HttpServletRequest request){
        if("zhangsan".equals(request.getParameter("username")) && "123".equals(request.getParameter("password"))){
            HttpSession session = request.getSession();
            User user = new User();
            user.setUsername("zhangsan");
            user.setPassword("123");
            session.setAttribute("user", user);
            return "success";
        } else {
            return "fail";
        }
    }
}
controll与servlet关系:

查询鉴权架构 鉴权模式_JWT_07


事实上,DispatcherServlet是Spring中唯一的servlet,controller只是一个普通的JavaBean

  • 测试:
  1. 我们访问资源

    可以看到,此时我们没有认证,因此需要输入用户名和密码

2.我们输入错误的用户名和密码

查询鉴权架构 鉴权模式_查询鉴权架构_08


查询鉴权架构 鉴权模式_JWT_09

可以看到访问资源失败

  1. 此时我们重开一个页面,尝试重新访问资源,输入正确的用户名与密码(zhangsan, 123)
  2. 查询鉴权架构 鉴权模式_OAuth_10

  3. 可以看到访问成功
  4. 我们成功登录了之后,再重新开一个页面,访问资源
  5. 查询鉴权架构 鉴权模式_OAuth_11

我们可以发现,此时不用重新输入用户名和密码,直接登录成功

2.3 Token验证

2.3.1 认证过程及session- cookie、token对比
  1. 用户输入登录凭据
  2. 服务器验证凭据是否正确,然后返回一个经过签名的token
  3. 客户端存储token,可以存在cookie或local Storage
  4. 下一次对服务器的请求带上这个token
  5. 服务器对JWT进行解码,如果token有效,则处理该请求
  6. 一旦用户登出,客户端就销毁token
服务器给客户端一个加密后的令牌,客户端后面拿着这个令牌访问服务器,服务器验证该令牌能否解析成功

session - Cookie与Token对比:

  • . 用户登录状态保存不同:sessionId只是一个标识的字符串,服务端根据这个字符串来查询服务端保持的session,session里面才保存着用户登录状态。但是token本身就是一种登录成功的凭证,他是在用户登录成功之后,根据某种规则生成的一种信息凭证,token里面本身就保存着用户登录状态,服务端只需要根据定义的规则校验这个token是否合法即可。

(1)服务端存储数据:session-cookie服务器需要存储session数据,而使用token,服务器只需要校验token是否合法

  • session-cookie是需要cookie配合的,居然要cookie,那么在http代理客户端的选择上就是只有浏览器了,因为只有浏览器才会去解析请求响应头里面的cookie,然后每次请求再默认带上该域名下的cookie。但是我们知道http代理客户端不只有浏览器,还有原生APP等等,这个时候cookie是不起作用的,或者浏览器端是可以禁止cookie的,但是token就不一样,他是登陆请求在登陆成功后再请求响应体中返回的信息,客户端在收到响应的时候,可以把他存在本地的cookie,storage,或者内存中,然后再下一次请求的请求头重带上这个token就行了。简单点来说cookie-session机制他限制了客户端的类型,而token验证机制丰富了客户端类型。

(2)局限性:session-cookie是基于cookie的,也就是说HTTP代理客户端的选择上只有浏览器。而使用token,选择很多,我们可以将token存在cookie,storage或者内存中

  • 时效性。session-cookie的sessionid是在登陆的时候生成的而且在登出时是一直不变的,在一定程度上安全就会低,而token是可以在一段时间内动态改变的。

(3)时效性/安全性:session-cookie在登出之前,session_id是一直不变的。而token在一段时间内是可以动态改变的,更安全。

  • 可扩展性。token验证本身是比较灵活的,一是token的解决方案有许多,常用的是JWT,二来我们可以基于token验证机制,专门做一个鉴权服务,用它向多个服务的请求进行统一鉴权。

(4)可扩展性:token验证更加灵活,我们可以基于自己的需要实现自己独特的鉴权服务。

2.3.2 代码演示

JWT:Json Web Token
本次演示的思路:

  1. 用法发起登录请求
  2. 服务端创建一个加密后的JWT信息,作为Token返回
  3. 在后续请求中JWT信息作为请求头,发给服务端
  4. 服务端拿到JWT之后进行解密,正确解密则表示此次请求合法,验证通过;解密失败则说明Token无效或过期。
JWT主要包含三部分:
- Header头部:主要由令牌类型(JWT)和使用的签名算法(HMAC SHA256 RSA)组成
- Payload负载:有效负载(通信双方要交换的内容)
- Signature 签名/签证

查询鉴权架构 鉴权模式_查询鉴权架构_12


官网地址:https://jwt.io/

①编写JWT工具类【重要】:

/**
 * @author 夏末
 * @description TODO
 * @date 2022/9/29 10:05
 */
public class JwtUtils {

    //过期时间
    public static final long EXPIRE_TIME = 1800000L;

    public JwtUtils() {
    }

    /**
     * 验证token是否合法
     * @param token
     * @param username
     * @param secret
     * @return
     */
    public static boolean verify(String token, String username, String secret) {
        try {
            //密钥+加密算法
            Algorithm algorithm = Algorithm.HMAC256(secret);
            //生成解析器 根据密钥+算法+负载
            JWTVerifier verifier = JWT.require(algorithm).withClaim("username", username).build();
            //看能否正确解析
            DecodedJWT verify = verifier.verify(token);
//            String payload = verify.getPayload();
//            System.out.println("payload: " + payload);
            return true;
        } catch (Exception e) {
            return false;
        }
    }

    /**
     * 根据token获取用户名
     * @param token
     * @return
     */
    public static String getUsername(String token) {
        try {
            //解析token,获取对应的负载
            DecodedJWT jwt = JWT.decode(token);
            return jwt.getClaim("username").asString();
        } catch (JWTDecodeException var2) {
            return null;
        }
    }

    /**
     * 根据数据+密钥生成对应的token
     * @param username
     * @param secret
     * @return
     */
    public static String getToken(String username, String secret) {
        Date date = new Date(System.currentTimeMillis() + EXPIRE_TIME);
        Algorithm algorithm = Algorithm.HMAC256(secret);
        return JWT.create().withClaim("username", username).withExpiresAt(date).sign(algorithm);
    }

    /**
     * 根据请求头获取到对应的token,判断里面的数据是否合法
     * @param request
     * @return
     * @throws MyException
     */
    public static String getUserNameByToken(HttpServletRequest request) throws MyException {
        String accessToken= request.getParameter("token");
        if (accessToken== null) {
            accessToken= request.getHeader("X-Access-Token");
        }
        String username = getUsername(accessToken);
        if (StringUtils.isEmpty(username)) {
            throw new MyException("未获取到用户");
        } else {
            return username;
        }
    }
}

②自定义异常(可省略)

public class MyException extends Exception{

    private String msg;

    public MyException(){

    }

    public String getMsg() {
        return msg;
    }

    public MyException(String msg){
        this.msg = msg;
    }
}

③controller层编写

@RestController
public class JWTController {

    private static String SECRET = "sodfas";

    private static String USERNAME = "zhangsan";

    @PostMapping("/login")
    public String login(HttpServletRequest request){
        try {
            String username = JwtUtils.getUserNameByToken(request);
            //没有报错表明登录成功,获取用户名
            return username + " login success...";
        } catch (MyException e) {
            System.out.println(e.getMsg());
            return "login fail....";
        }
    }

    public static void main(String[] args) {
        //根据密钥和用户信息生成对应的token
        String token = JwtUtils.getToken(USERNAME, SECRET);
        System.out.println(token);
        //eyJ0eXAiOiJKV1QiLCJhbGciOiJIUzI1NiJ9.eyJleHAiOjE2NjQ0MjE1NTMsInVzZXJuYW1lIjoiemhhbmdzYW4ifQ.ViYnwNk_0CuvJgfp-Mn9TKsrqd0A8W8jLDyry8hncjY
    }
}

④测试:

  • 利用postman发起请求

填写正确的token之后,可以看到正确返回信息

  • 此时,我们修改header中的token信息(随意改成错误的)

    可以看到登录失败,此时控制台信息:

参考文章:
https://cloud.tencent.com/developer/article/1375870

2.4 OAuth(开放授权)

2.4.1 定义及过程

OAUTH协议为用户资源的授权提供了一个安全的、开放而又简易的标准。与以往的授权方式不同之处是OAUTH的授权不会使第三方触及到用户的帐号信息(如用户名与密码),即第三方无需使用用户的用户名与密码就可以申请获得该用户资源的授权,因此OAUTH是安全的。同时,任何第三方都可以使用OAUTH认证服务,任何服务提供商都可以实现自身的OAUTH认证服务,因而OAUTH是开放的。

我们常见的提供OAuth认证服务的厂商有支付宝,QQ,微信。

OAuth协议又有1.0和2.0两个版本。相比较1.0版,2.0版整个授权验证流程更简单更安全,也是目前最主要的用户身份验证和授权方式。

案例:
一个用户拥有2项服务:①图片在线存储服务A ②图片在线打印服务B。由于服务A与B是由两家不同的服务提供商提供的,所以用户在这两家服务提供商的网站上各自注册了用户,假设这两个用户名与密码均不相同。

  • 当用户要使用服务B打印存储在服务A上的图片时,用户该如何处理?
方法一:用户可能先将待打印的图片从服务A上下载下来并上传到服务B上打印,这种方式安全但处理比较繁
琐,效率低下;

方法二:用户将在服务A上注册的用户名与密码提供给服务B,服务B使用用户的帐号再去服务A处下载待打印的
图片,这种方式效率是提高了,但是安全性大大降低了,服务B可以使用用户的用户名与密码去服务A上查看甚
至篡改用户的资源。

方法三:当服务B(打印服务)要访问用户的服务A(图片服务)时,通过OAUTH机制,服务B向服务A请求未经
用户授权的RequestToken后,服务A将引导用户在服务A的网站上登录,并询问用户是否将图片服务授权给服
务B。用户同意后,服务B就可以访问用户在服务A上的图片服务。整个过程服务B没有触及到用户在服务A的帐
号信息。
"由此可见OAUTH的安全性与便利性"

在OAUTH认证和授权过程中涉及的三方:

服务提供方(ServiceProvider),用户使用服务提供方来存储受保护的资源,如照片,视频,联系人列表。

用户(User),存放在服务提供方的受保护的资源的拥有者

客户端(Consumer),要访问服务提供方资源的第三方应用,通常是网站,如提供照片打印服务的网站也可以
是桌面或移动应用程序。在认证过程之前,客户端要向服务提供者申请客户端标识。

OAUTH认证中相关的URL:

RequestToken URL:获取未授权的RequestToken服务地址;

UserAuthorization URL:获取用户授权的RequestToken服务地址;

AccessToken URL:用授权的RequestToken换取AccessToken的服务地址。

OAUTH认证和授权过程:

  1. 客户端(第三方软件)向OAUTH服务提供商请求未授权的RequestToken。即向RequestToken URL发起请求;
  2. OAUTH服务提供商同意使用者的请求,并向其颁发未经用户授权的oauth_token与对应的oauth_token_secret,并返回给使用者;
  3. 使用者向OAUTH服务提供商请求用户授权的RequestToken。即向UserAuthorization URL发起请求并在请求中携带上一步服务提供商颁发的未授权的token与其密钥;
  4. OAUTH服务提供商通过网页要求用户登录并引导用户完成授权;
  5. RequestToken授权后,使用者将向AccessToken URL发起请求,将上步授权的RequestToken换取成AccessToken。请求的参数见上图,这个比第一步多了一个参数就是RequestToken;
  6. OAUTH服务提供商同意使用者的请求,并向其颁发AccessToken与对应的密钥,并返回给使用者;
  7. 使用者以后就可以使用上步返回的AccessToken访问用户授权的资源。

简单来说就"三步骤":

  1. 获取未授权的RequestToken
  2. 获取用户授权的RequestToken
  3. 用授权的RequestToken换取AccessToken
2.4.2 代码演示

查询鉴权架构 鉴权模式_JWT_13