一、认证
认证, 用另一种方式来说, 就是用户证明自身身份的证据, 展示个人ID和输入用户密码是两种证明自身身份常见的方法。
通常情况下,只有通过认证, 网站才能为用户提供敏感资源。
HTTP自身拥有认证机制, 该机制允许服务器(向用户)提出质询并获取其所需的认证信息。
1.认证方式
认证是Web服务器标识用户的一种方式。用户需要摆出证据, 证明其有获得所请求资源的权限。
通常, 证明的过程需要一组用户名和密码, 并且, 输入的用户名和密码必须经服务器(验证后)认同是有效的, 然后才由服务器判断该用户是否有获取该资源的权限。
HTTP/1.1 使用的认证方式有
- BASIC 认证(基本认证)
- DIGEST 认证(摘要认证)
- SSL 客户端认证
- 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-SHA256realm=<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;
}
}