security · 2022-11-30 0

HTTP 的认证方式

一、认证

认证, 用另一种方式来说, 就是用户证明自身身份的证据, 展示个人ID和输入用户密码是两种证明自身身份常见的方法。

通常情况下,只有通过认证, 网站才能为用户提供敏感资源。

HTTP自身拥有认证机制, 该机制允许服务器(向用户)提出质询并获取其所需的认证信息。

1.认证方式

认证是Web服务器标识用户的一种方式。用户需要摆出证据, 证明其有获得所请求资源的权限。

通常, 证明的过程需要一组用户名和密码, 并且, 输入的用户名和密码必须经服务器(验证后)认同是有效的, 然后才由服务器判断该用户是否有获取该资源的权限。

HTTP/1.1 使用的认证方式有

  1. BASIC 认证(基本认证)
  2. DIGEST 认证(摘要认证)
  3. SSL 客户端认证
  4. FormBase 认证(基于表单认证)

HTTP WWW-Authenticate 响应标头定义了 HTTP 身份验证的方法(“质询”),它用于获取特定资源的访问权限。

使用 HTTP 身份验证的服务器将以 401 Unauthorized 响应去响应受保护资源的请求。该响应必须包含至少一个 WWW-Authenticate 标头和至少一个质询,以指示使用哪些身份验证方案访问资源(以及每个特定方案的任意额外的数据)。

语法:

WWW-Authenticate: <auth-scheme>
WWW-Authenticate: <auth-scheme> realm=<realm>
WWW-Authenticate: <auth-scheme> token68
WWW-Authenticate: <auth-scheme> auth-param1=token1, ..., auth-paramN=auth-paramN-token
WWW-Authenticate: <auth-scheme> realm=<realm> token68
WWW-Authenticate: <auth-scheme> realm=<realm> token68 auth-param1=auth-param1-token , ..., auth-paramN=auth-paramN-token
WWW-Authenticate: <auth-scheme> realm=<realm> auth-param1=auth-param1-token, ..., auth-paramN=auth-paramN-token
WWW-Authenticate: <auth-scheme> token68 auth-param1=auth-param1-token, ..., auth-paramN=auth-paramN-token
  • <auth-scheme>:身份验证方案。一些更常见的类型是(不区分大小写):Basic、Digest、Negotiate 和 AWS4-HMAC-SHA256
  • realm=<realm> 可选:描述受保护区域的字符串。realm 允许服务器对它受保护的区域进行区分(如果允许支持这种划分方案),并通知用户需要哪个特定的用户名/密码。如果未指定 realm,客户端通常会显示格式化的主机名
  • <token68> 可选:一个 token,可能对某些方案有用。该 token 允许使用 66 个未保留的 URI 字符以及其他的一些字符。根据规范,它可以支持 base64、base64url、base32 或者 base16(十六进制)编码,有或者没有填充,但是不包括空格

https://developer.mozilla.org/zh-CN/docs/Web/HTTP/Headers/WWW-Authenticate

2.质询/响应认证

就是说, 在某个客户端发送请求的时候, 服务器不会立即响应, 而是(向客户端)返回认证要求。该认证要求要求用户通过输入私密信息(用户名及密码)来提供标识身份的证明。

之后, 客户端会重复发送请求来提供(身份)凭证, 如果该身份凭证被服务器认证是有效的, 那么用户可以得到所请求的响应资源。如果(身份)凭证被认为无效, 那么服务器会重新发出质询, 或直接发送(认证信息)错误报文。

注:加入用户名密码错误的话, 服务器会返回401

二、BASIC 认证(基本认证)

BASIC 认证(基本认证)是从HTTP/1. 1 就定义的认证方式,是Web服务器与通信客户端之间进行的认证方式。

1.参数

  • <realm> 可选
  • charset="UTF-8" 可选:当提交用户名和密码时,告诉客户端服务器的首选编码方案。仅允许的值是不区分大小写的“UTF-8”字符串。这与 realm 字符串的编码无关

2.认证步骤

1) 客户端 -> 服务端

GET /auth/basic HTTP/1.1

2) 服务端 -> 客户端

HTTP/1.1 401
WWW-Authenticate: basic realm="no auth"

3) 客户端 -> 服务端

GET /auth/basic HTTP/1.1
Authorization: Basic YWRtaW46MTIzNDU2

4) 客户端 -> 服务端

HTTP/1.1 200

(对于 google 浏览器接受到状态码 401 和响应头 WWW-Authenticate: basic,会弹出一个框,用于用户输入用户名和密码,点击确定后,会进行添加到 Authorization 请求头。Authorization 的值是,用户名和密码构成,两者中间以冒号连接后,再经过 Base64 编码处理,例,admin:123456 对应的 Base64 编码是 YWRtaW46MTIzNDU2)

basic 认证,存在着安全问题,用base64编码字符串,可以被轻易地解开。

实际上, base64 编码不是为了加密, 而是为了保证用户名及密码在通过HTTP协议传输之后具有可移植性。不过,在HTTP首部字段中不能使用通用字符, 才是编码的主要原因。

三、DIGEST 认证(摘要认证)

1.参数

  • <realm> 可选:一个指示要使用的用户名/密码的字符串。至少应该包括主机名,但是可能指示具有访问权限的用户或组
  • domain 可选:一个带引号,以空格分隔的 URI 前缀列表,定义了可以使用身份验证信息的所有位置。如果未指定此关键字,则可以在 web 根目录的任意位置使用身份验证信息
  • nonce:一个服务器指定的带引号的字符串,在每次的 401 响应期间,服务器可以使用它去验证指定的凭据。这必须是在每次 401 响应时唯一的生成,并且可以更频繁地重新生成(例如,允许一个摘要仅使用一次)。该规范包含有关生成此值算法的建议。nonce 值对客户端是不透明的
  • opaque:一个服务器指定的带引号的字符串,应在 Authorization 中原封不动的返回。这对客户端是不透明的。建议服务器包含 Base64 或十六进制数据
  • stale 可选:一个不区分大小写的标志,指示客户端之前的请求因 nonce 太旧了(过期)而被拒绝。如果为 true,则可以使用新的 nonce 加密相同用户名/密码重试请求。如果它是任意其他的值,那么用户名/密码无效,并且必须向用户重新请求
  • algorithm 可选:algorithm 被用于产生一个摘要。有效的非会话值是:"MD5"(如果未指定,则是默认)、"SHA-256"、"SHA-512"。有效的会话值是:"MD5-sess"、"SHA-256-sess"、"SHA-512-sess"
  • qop:带引号的字符串,表示服务器支持的保护程度。这必须提供,并且必须忽略无法识别的选项。"auth":身份验证;"auth-int":有完整保护的身份验证
  • charset="UTF-8" 可选:当提交用户名和密码时,告诉客户端服务器的首选编码方案。仅允许的值是不区分大小写的“UTF-8”字符串
  • userhash 可选:服务器可能指定为 "true",以指示它支持用户名哈希(默认是 "false")

2.认证步骤

1) 客户端 -> 服务端

GET /auth/digest HTTP/1.1

2) 服务端 -> 客户端

HTTP/1.1 401
WWW-Authenticate: Digest realm="no auth",nonce="XK2ST6OkfvcCgvAjnDTtng==",qop="auth"

3) 客户端 -> 服务端

GET /auth/digest HTTP/1.1
Authorization: Digest username="admin", realm="no auth", nonce="XK2ST6OkfvcCgvAjnDTtng==", uri="/auth/digest", response="4f13b0966ba4b70b09bd52b826fabe75", qop=auth, nc=00000002, cnonce="1eff95048ac7abc3"

4) 客户端 -> 服务端

HTTP/1.1 200

客户端再次请求:

1) 客户端 -> 服务端

GET /auth/digest HTTP/1.1
Authorization: Digest username="admin", realm="no auth", nonce="XK2ST6OkfvcCgvAjnDTtng==", uri="/auth/digest", response="8d8780dc767fb90555bc945f87a30d3b", qop=auth, nc=00000003, cnonce="a7067aca721218cc"

2) 客户端 -> 服务端

HTTP/1.1 200

(对于 google 浏览器接受到状态码 401 和响应头 WWW-Authenticate: Digest nonce,会弹出一个框,用于用户输入用户名和密码,点击确定后,会进行添加到 Authorization 请求头。服务器响应:realm nonce qop,请求需要:username realm nonce uri response qop nc cnonce response)

response 的值计算为:MD5(MD5(username:realm:password):nonce:nc:cnonce:qop:MD5(<request-method>:url))

digest认证的优点:

  • 不会在网络中发送明文密码
  • 防止重放攻击
  • 防止篡改报文

无论是密码、用户名、realm、密码的摘要都要求是可恢复的

反观简单的basic认证, 结合SSL使用之后比(复杂的)digest认证安全得多——因此该认证方法得到大力推广。

四、SSL 客户端认证

1.认证步骤

五、FormBase 认证(基于表单认证)

基于表单的认证方法并不是 HTTP 协议中定义的 。

客户端会向服务器上的 Web 应用程序发送登陆信息,按登陆信息的验证结果认证。根据 Web 应用程序的实际安装,提供的用户界面及认证方式也各不相同。多数情况下,输入用户名 ID 和密码等登陆信息后,发送给 Web 应用程序,基于认证结果来决定认证是否成功。

由于使用上的便利性及安全性问题,HTTP 协议标准提供的 BASIC 认证和 DIGEST 认证几乎不怎么使用。另外,SSL 客户端认证虽然具有高度的安全等级,但因为导入及维持费用等问题,还尚未普及。

不具备共同标准规范的表单认证,在每个 Web 网站上会有各自不同的实现方式。如果时全面考虑过安全性能而实现的表单认证,那么就能够具备高度的安全等级。

基于表单认证一般会使用Cookie 来管理 Session(会话)。

1.认证步骤

1) 客户端 -> 服务端

请求体的数据格式,与 Content-Type 对应

POST /auth/formbase HTTP/1.1
Content-Type: application/x-www-form-urlencoded

username=admin&password=123456

2) 服务端 -> 客户端

HTTP/1.1 200
Set-Cookie: JSESSIONID=A399E05EF08B388012E969AC24A309D2; Path=/; HttpOnly

客户端请求时,携带登陆信息。服务端验证成功后,会返回 Set-Cookie 的响应头。以后前端请求,携带 Cookie 请求头,可访问需要权限的资源。

登陆后,访问需要权限资源:

1) 客户端 -> 服务端

GET /userinfo HTTP/1.1
Cookie: JSESSIONID=A399E05EF08B388012E969AC24A309D2

2) 服务端 -> 客户端

HTTP/1.1 200

六、实现

1.maven 依赖

<parent>
    <groupId>org.springframework.boot</groupId>
    <artifactId>spring-boot-starter-parent</artifactId>
    <version>2.2.2.RELEASE</version>
    <relativePath/>
</parent>

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

    <dependency>
        <groupId>org.projectlombok</groupId>
        <artifactId>lombok</artifactId>
        <version>1.18.28</version>
        <scope>provided</scope>
    </dependency>
</dependencies>

2.BASIC 认证

BasicController.java

@RestController
public class BasicController {

    @GetMapping("/auth/basic")
    public String auth(HttpServletRequest req, HttpServletResponse res) {
        if (isAuth(req, res)) {
            return "{code: 0}";
        }

        challenge(res);
        return "{code: 401}";
    }

    private boolean isAuth(HttpServletRequest req, HttpServletResponse res) {
        String base6AuthStr = req.getHeader("Authorization");
        // base6AuthStr=Basic YWRtaW46MTIzNDU2
        System.out.println("base6AuthStr=" + base6AuthStr);
        if (base6AuthStr == null || base6AuthStr.length() <= 6) {
            return false;
        }

        String authStr = new String(Base64.getDecoder().decode(base6AuthStr.substring(6).getBytes()));
        // authStr=admin:123456
        System.out.println("authStr=" + authStr);

        String[] arr = authStr.split(":");
        if (arr != null && arr.length == 2) {
            String username = arr[0];
            String password = arr[1];
            // 校验用户名和密码
            if ("admin".equals(username) && "123456".equals(password)) {
                return true;
            }
        }

        return false;
    }

    private boolean challenge(HttpServletResponse res) {
        res.setStatus(401);
        res.addHeader("WWW-Authenticate", "basic realm="no auth"");
        return false;
    }
}

3.DIGEST 认证

DigestController.java

@RestController
public class DigestController {

    /**
     * 为了 测试Digest nc 值每次请求增加
     */
    private int nc = 0;

    @GetMapping("/auth/digest")
    public String auth(HttpServletRequest req, HttpServletResponse res) {
        if (isAuth(req, res)) {
            return "{code: 0}";
        }
        challenge(res);
        return "{code: 401}";
    }

    private boolean isAuth(HttpServletRequest req, HttpServletResponse res) {
        String authStr = req.getHeader("Authorization");
        System.out.println("authStr=" + authStr);
        if (authStr == null || authStr.length() <= 7) {
            return false;
        }

        DigestAuthInfo authObject = DigestUtils.getAuthInfoObject(authStr);

        /**
         * 生成 response 的算法:
         *
         * 有 qop,则 response = MD5(MD5(username:realm:password):nonce:nc:cnonce:qop:MD5(<request-method>:url))
         * 无 qop,则 response = MD5(MD5(username:realm:password):nonce:MD5(<request-method>:url))
         */
        // 这里用户名 admin,密码 123456, 实际应用需要根据用户名查询数据库或缓存获得
        String HA1 = DigestUtils.MD5("admin:no auth:123456");
        String HD = authObject.getNonce() + ":" + authObject.getNc() + ":" + authObject.getCnonce() + ":" + authObject.getQop();
        String HA2 = DigestUtils.MD5(req.getMethod() + ":" + authObject.getUri());

        String responseValid = DigestUtils.MD5(HA1 + ":" + HD + ":" + HA2);

        // 如果 Authorization 中的 response(浏览器生成的) 与期望的 response(服务器计算的) 相同,则验证通过
        System.out.println("Authorization response: " + authObject.getResponse());
        System.out.println("responseValid: " + responseValid);
        if (responseValid.equals(authObject.getResponse())) {
            /* 判断 nc 的值,用来防重放攻击 */
            // 判断此次请求的 Authorization 请求头里面的 nc 值是否大于之前保存的 nc 值
            // 大于,替换旧值,然后 return true
            // 否则,return false

            // 测试代码 start
            int newNc = Integer.parseInt(authObject.getNc(), 16);
            System.out.println("old nc: " + this.nc + ", new nc: " + newNc);
            if (newNc > this.nc) {
                this.nc = newNc;
                return true;
            }
            return false;
            // 测试代码 end
        }

        return false;
    }

    /**
     * 质询:返回状态码 401 和 WWW-Authenticate 响应头
     *
     * 服务器返回:realm nonce qop,请求需要:username realm nonce uri response qop nc cnonce
     * 服务器返回:realm nonce,请求需要:username realm nonce uri response
     */
    private boolean challenge(HttpServletResponse res) {
        // 质询前,重置或删除保存的与该用户关联的 nc 值(nc:nonce计数器,是一个16进制的数值,表示同一nonce下客户端发送出请求的数量)
        this.nc = 0;

        res.setStatus(401);
        String str = MessageFormat.format("Digest realm={0},nonce={1},qop={2}", ""no auth"",
                """ + DigestUtils.generateToken() + """, ""auth"");
        res.addHeader("WWW-Authenticate", str);
        return false;
    }
}

DigestAuthInfo.java

import lombok.Getter;
import lombok.Setter;

@Setter
@Getter
public class DigestAuthInfo {

    private String username;

    private String realm;

    private String nonce;

    private String uri;

    private String response;

    private String qop;

    private String nc;

    public String cnonce;
}

DigestUtils.java

public class DigestUtils {

    /**
     * 根据当前时间戳生成一个随机字符串
     */
    public static String generateToken() {
        try {
            String s = String.valueOf(System.currentTimeMillis() + new Random().nextInt());
            MessageDigest md5 = MessageDigest.getInstance("MD5");
            byte[] digest = md5.digest(s.getBytes());

            return Base64.getEncoder().encodeToString(digest);
        } catch (NoSuchAlgorithmException e) {
            throw new RuntimeException();
        }
    }

    public static String MD5(String inStr) {
        try {
            MessageDigest md5 = MessageDigest.getInstance("MD5");

            byte[] md5Bytes = md5.digest(inStr.getBytes());
            StringBuffer hexValue = new StringBuffer();

            for (int i = 0; i < md5Bytes.length; i++) {
                int val = ((int) md5Bytes[i]) & 0xff;
                if (val < 16) {
                    hexValue.append("0");
                }
                hexValue.append(Integer.toHexString(val));
            }
            return hexValue.toString();
        } catch (Exception e) {
            return "";
        }
    }

    /**
     * 该方法用于将 Authorization 请求头的内容封装成一个对象。
     *
     * Authorization 请求头的内容为:
     *     Digest username="aaa", realm="no auth", nonce="b2b74be03ff44e1884ba0645bb961b53",
     *     uri="/BootDemo/login", response="90aff948e6f2207d69ecedc5d39f6192", qop=auth,
     *     nc=00000002, cnonce="eb73c2c68543faaa"
     */
    public static DigestAuthInfo getAuthInfoObject(String authStr) {
        if (authStr == null || authStr.length() <= 7) {
            return null;
        }

        if (authStr.toLowerCase().indexOf("digest") >= 0) {
            // 截掉前缀 Digest
            authStr = authStr.substring(6);
        }

        // 将双引号去掉
        authStr = authStr.replaceAll(""", "");

        DigestAuthInfo digestAuthObject = new DigestAuthInfo();
        String[] authArray = authStr.split(",");

        for (int i = 0, len = authArray.length; i < len; i++) {
            String auth = authArray[i];
            String key = auth.substring(0, auth.indexOf("=")).trim();
            String value = auth.substring(auth.indexOf("=") + 1).trim();
            switch (key) {
                case "username":
                    digestAuthObject.setUsername(value);
                    break;
                case "realm":
                    digestAuthObject.setRealm(value);
                    break;
                case "nonce":
                    digestAuthObject.setNonce(value);
                    break;
                case "uri":
                    digestAuthObject.setUri(value);
                    break;
                case "response":
                    digestAuthObject.setResponse(value);
                    break;
                case "qop":
                    digestAuthObject.setQop(value);
                    break;
                case "nc":
                    digestAuthObject.setNc(value);
                    break;
                case "cnonce":
                    digestAuthObject.setCnonce(value);
                    break;
            }
        }
        return digestAuthObject;
    }
}

4.FormBase 认证

FormBaseController.java

@RestController
public class FormBaseController {

    @PostMapping("/auth/formbase")
    public String auth(HttpServletRequest req, HttpServletResponse res) {
        if (isAuth(req, res)) {
            return "{code: 0}";
        }
        return "{code: 401}";
    }

    @GetMapping("/userinfo")
    public String userinfo(HttpServletRequest req, HttpServletResponse res) {
        HttpSession session = req.getSession(false);
        String username = session == null ? null : (String) session.getAttribute("username");

        if (username == null || "".equals(username)) {
            res.setStatus(401);
            return "{code: 401}";
        }

        return String.format("{username: %s}", username);
    }

    private boolean isAuth(HttpServletRequest req, HttpServletResponse res) {
        String username = req.getParameter("username");
        String password = req.getParameter("password");

        if ("admin".equals(username) && "123456".equals(password)) {
            HttpSession session = req.getSession(true);
            session.setAttribute("username", "admin");
            session.setAttribute("status", 0);
            return true;
        }
        return false;
    }
}