前言
作為一個JAVA開發,之前有好幾次出去面試,面試官都問我,JAVAWeb掌握的怎么樣,我當時就不知道怎么回答,Web,日常開發中用的是什么?今天我們來說說JAVAWeb最應該掌握的三個內容。
發展歷程
1、很久很久以前,Web 基本上就是文檔的瀏覽而已, 既然是瀏覽,作為服務器, 不需要記錄誰在某一段時間里都瀏覽了什么文檔,每次請求都是一個新的HTTP協議, 就是請求加響應, 尤其是我不用記住是誰剛剛發了HTTP請求, 每個請求對我來說都是全新的。
2、但是隨着交互式Web應用的興起,像在線購物網站,需要登錄的網站等等,馬上就面臨一個問題,那就是要管理會話,必須記住哪些人登錄系統, 哪些人往自己的購物車中放商品, 也就是說我必須把每個人區分開,這就是一個不小的挑戰,因為HTTP請求是無狀態的,所以想出的辦法就是給大家發一個會話標識(session id), 說白了就是一個隨機的字串,每個人收到的都不一樣, 每次大家向我發起HTTP請求的時候,把這個字符串給一並捎過來, 這樣我就能區分開誰是誰了。
3、這樣大家很嗨皮了,可是服務器就不嗨皮了,每個人只需要保存自己的session id,而服務器要保存所有人的session id ! 如果訪問服務器多了, 就得由成千上萬,甚至幾十萬個。
這對服務器說是一個巨大的開銷 , 嚴重的限制了服務器擴展能力, 比如說我用兩個機器組成了一個集群, 小F通過機器A登錄了系統, 那session id會保存在機器A上, 假設小F的下一次請求被轉發到機器B怎么辦? 機器B可沒有小F的 session id啊。
有時候會采用一點小伎倆: session sticky , 就是讓小F的請求一直粘連在機器A上, 但是這也不管用, 要是機器A掛掉了, 還得轉到機器B去。
那只好做session 的復制了, 把session id 在兩個機器之間搬來搬去, 快累死了。
后來有個叫Memcached的支了招: 把session id 集中存儲到一個地方, 所有的機器都來訪問這個地方的數據, 這樣一來,就不用復制了, 但是增加了單點失敗的可能性, 要是那個負責session 的機器掛了, 所有人都得重新登錄一遍, 估計得被人罵死。
也嘗試把這個單點的機器也搞出集群,增加可靠性, 但不管如何, 這小小的session 對我來說是一個沉重的負擔
4 於是有人就一直在思考, 我為什么要保存這可惡的session呢, 只讓每個客戶端去保存該多好?
可是如果不保存這些session id , 怎么驗證客戶端發給我的session id 的確是我生成的呢? 如果不去驗證,我們都不知道他們是不是合法登錄的用戶, 那些不懷好意的家伙們就可以偽造session id , 為所欲為了。
嗯,對了,關鍵點就是驗證 !
比如說, 小F已經登錄了系統, 我給他發一個令牌(token), 里邊包含了小F的 user id, 下一次小F 再次通過Http 請求訪問我的時候, 把這個token 通過Http header 帶過來不就可以了。
不過這和session id沒有本質區別啊, 任何人都可以可以偽造, 所以我得想點兒辦法, 讓別人偽造不了。
那就對數據做一個簽名吧, 比如說我用HMAC-SHA256 算法,加上一個只有我才知道的密鑰, 對數據做一個簽名, 把這個簽名和數據一起作為token , 由於密鑰別人不知道, 就無法偽造token了。
這個token 我不保存, 當小F把這個token 給我發過來的時候,我再用同樣的HMAC-SHA256 算法和同樣的密鑰,對數據再計算一次簽名, 和token 中的簽名做個比較, 如果相同, 我就知道小F已經登錄過了,並且可以直接取到小F的user id , 如果不相同, 數據部分肯定被人篡改過, 我就告訴發送者: 對不起,沒有認證。
Token 中的數據是明文保存的(雖然我會用Base64做下編碼, 但那不是加密), 還是可以被別人看到的, 所以我不能在其中保存像密碼這樣的敏感信息。
當然, 如果一個人的token 被別人偷走了, 那我也沒辦法, 我也會認為小偷就是合法用戶, 這其實和一個人的session id 被別人偷走是一樣的。
這樣一來, 我就不保存session id 了, 我只是生成token , 然后驗證token , 我用我的CPU計算時間獲取了我的session 存儲空間 !
解除了session id這個負擔, 可以說是無事一身輕, 我的機器集群現在可以輕松地做水平擴展, 用戶訪問量增大, 直接加機器就行。 這種無狀態的感覺實在是太好了!
Cookie
1.什么是Cookie
Cookie翻譯成中文的意思是‘小甜餅’,是由W3C組織提出,最早由Netscape社區發展的一種機制。目前Cookie已經成為標准,所有的主流瀏覽器如IE、Netscape、Firefox、Opera等都支持Cookie。
服務器單從網絡連接上無從知道客戶身份。怎么辦呢?就給客戶端們頒發一個通行證吧,每人一個,無論誰訪問都必須攜帶自己通行證。這樣服務器就能從通行證上確認客戶身份了。這就是Cookie的工作原理。
Cookie是客戶端保存用戶信息的一種機制,用來記錄用戶的一些信息,也是實現Session的一種方式。Cookie存儲的數據量有限,且都是保存在客戶端瀏覽器中。不同的瀏覽器有不同的存儲大小,但一般不超過4KB。因此使用Cookie實際上只能存儲一小段的文本信息(key-value格式)。
2.Cookie的機制
當用戶第一次訪問並登陸一個網站的時候,cookie的設置以及發送會經歷以下4個步驟:
-
客戶端發送一個請求到服務器;
-
服務器發送一個HttpResponse響應到客戶端,其中包含Set-Cookie的頭部;
-
客戶端保存cookie,之后向服務器發送請求時,HttpRequest請求中會包含一個Cookie的頭部;
-
服務器返回響應數據。
為了探究這個過程,寫了代碼進行測試,如下:
我在doGet方法中,new了一個Cookie對象並將其加入到了HttpResponse對象中
@RestController
public class TestController {
@GetMapping(value = "/doGet")
public void doGet(HttpServletRequest req, HttpServletResponse resp) throws ServletException, IOException {
Cookie cookie = new Cookie("jiangwang",System.currentTimeMillis()+"");
// 設置生命周期為MAX_VALUE
cookie.setMaxAge(Integer.MAX_VALUE);
resp.addCookie(cookie);
}
}
瀏覽器輸入地址進行訪問,結果如圖所示:
可見Response Headers中包含Set-Cookie頭部,而Request Headers中包含了Cookie頭部。name和value正是上述設置的。
3.Cookie的屬性
Expires
該屬性用來設置Cookie的有效期。Cookie中的maxAge用來表示該屬性,單位為秒。Cookie中通過getMaxAge()和setMaxAge(int maxAge)來讀寫該屬性。maxAge有3種值,分別為正數,負數和0。
如果maxAge屬性為正數,則表示該Cookie會在maxAge秒之后自動失效。瀏覽器會將maxAge為正數的Cookie持久化,即寫到對應的Cookie文件中(每個瀏覽器存儲的位置不一致)。無論客戶關閉了瀏覽器還是電腦,只要還在maxAge秒之前,登錄網站時該Cookie仍然有效。下面代碼中的Cookie信息將永遠有效。
Cookie cookie = new Cookie("jiangwang",System.currentTimeMillis()+"");
// 設置生命周期為MAX_VALUE,永久有效
cookie.setMaxAge(Integer.MAX_VALUE);
resp.addCookie(cookie);
當maxAge屬性為負數,則表示該Cookie只是一個臨時Cookie,不會被持久化,僅在本瀏覽器窗口或者本窗口打開的子窗口中有效,關閉瀏覽器后該Cookie立即失效。
Cookie cookie = new Cookie("jiangwang",System.currentTimeMillis()+"");
// 設置生命周期為MAX_VALUE,永久有效
cookie.setMaxAge(-1);
resp.addCookie(cookie);
當maxAge為0時,表示立即刪除Cookie。
Cookie[] cookies = req.getCookies();
Cookie cookie = null;
// get Cookie
for (Cookie ck : cookies) {
if ("jiangwang".equals(ck.getName())) {
cookie = ck;
break;
}
}
if (null != cookie) {
// 刪除一個cookie
cookie.setMaxAge(0);
resp.addCookie(cookie);
}
修改或者刪除Cookie
HttpServletResponse提供的Cookie操作只有一個addCookie(Cookie cookie),所以想要修改Cookie只能使用一個同名的Cookie來覆蓋原先的Cookie。如果要刪除某個Cookie,則只需要新建一個同名的Cookie,並將maxAge設置為0,並覆蓋原來的Cookie即可。
新建的Cookie,除了value、maxAge之外的屬性,比如name、path、domain都必須與原來的一致才能達到修改或者刪除的效果。否則,瀏覽器將視為兩個不同的Cookie不予覆蓋。
Cookie的域名
Cookie是不可以跨域名的,隱私安全機制禁止網站非法獲取其他網站的Cookie。
正常情況下,同一個一級域名下的兩個二級域名也不能交互使用Cookie,比如a1.jiangwang.com
和a2.jiangwang.com
,因為二者的域名不完全相同。如果想要jiangwnag.com
名下的二級域名都可以使用該Cookie,需要設置Cookie的domain參數為.jiangwang.com
,這樣使用a1.jiangwang.com
和a2.jiangwang.com
就能訪問同一個cookie
一級域名又稱為頂級域名,一般由字符串+后綴組成。熟悉的一級域名有baidu.com,qq.com。com,cn,net等均是常見的后綴。
二級域名是在一級域名下衍生的,比如有個一級域名為abc.com
,則blog.abc.com
和www.abc.com
均是其衍生出來的二級域名。
Cookie的路徑
path屬性決定允許訪問Cookie的路徑。比如,設置為"/"表示允許所有路徑都可以使用Cookie
4.應用
Cookies最典型的應用是判定注冊用戶是否已經登錄網站,用戶可能會得到提示,是否在下一次進入此網站時保留用戶信息以便簡化登錄手續,這些都是Cookies的功用。另一個重要應用場合是“購物車”之類處理。用戶可能會在一段時間內在同一家網站的不同頁面中選擇不同的商品,這些信息都會寫入Cookies,以便在最后付款時提取信息。
Session
1.什么是Session
在WEB開發中,服務器可以為每個用戶瀏覽器創建一個會話對象(session對象),注意:一個瀏覽器獨占一個session對象(默認情況下)。因此,在需要保存用戶數據時,服務器程序可以把用戶數據寫到用戶瀏覽器獨占的session中,當用戶使用瀏覽器訪問其它程序時,其它程序可以從用戶的session中取出該用戶的數據,為用戶服務。
2.Session實現原理
服務器創建session出來后,會把session的id號,以cookie的形式回寫給客戶機,這樣,只要客戶機的瀏覽器不關,再去訪問服務器時,都會帶着session的id號去,服務器發現客戶機瀏覽器帶session id過來了,就會使用內存中與之對應的session為之服務。可以用如下的代碼證明:
@RestController
public class TestController {
@GetMapping(value = "/doGet")
public void doGet(HttpServletRequest request, HttpServletResponse response)
throws ServletException, IOException {
response.setCharacterEncoding("UTF-8");
response.setContentType("text/html;charset=UTF-8");
//使用request對象的getSession()獲取session,如果session不存在則創建一個
HttpSession session = request.getSession();
//將數據存儲到session中
session.setAttribute("mayun", "馬雲");
//獲取session的Id
String sessionId = session.getId();
//判斷session是不是新創建的
if (session.isNew()) {
response.getWriter().print("session創建成功,session的id是:"+sessionId);
}else {
response.getWriter().print("服務器已經存在該session了,session的id是:"+sessionId);
}
}
}
第一次訪問時,服務器會創建一個新的sesion,並且把session的Id以cookie的形式發送給客戶端瀏覽器,如下圖所示:
再次請求服務器,此時就可以看到瀏覽器再請求服務器時,會把存儲到cookie中的session的Id一起傳遞到服務器端了,如下圖所示:
3.session創建和銷毀
在程序中第一次調用request.getSession()方法時就會創建一個新的Session,可以用isNew()方法來判斷Session是不是新創建的
//使用request對象的getSession()獲取session,如果session不存在則創建一個
HttpSession session = request.getSession();
//獲取session的Id
String sessionId = session.getId();
//判斷session是不是新創建的
if (session.isNew()) {
response.getWriter().print("session創建成功,session的id是:"+sessionId);
}else {
response.getWriter().print("服務器已經存在session,session的id是:"+sessionId);
}
session對象默認30分鍾沒有使用,則服務器會自動銷毀session,也可以手工配置session的失效時間,例如:
session.setMaxInactiveInterval(10*60);//10分鍾后session失效
當需要在程序中手動設置Session失效時,可以手工調用session.invalidate方法,摧毀session。
HttpSession session = request.getSession();
//手工調用session.invalidate方法,摧毀session
session.invalidate();
面試題:瀏覽器關閉,session就銷毀了? 不對.
Session生成后,只要用戶繼續訪問,服務器就會更新Session的最后訪問時間,並維護該Session。為防止內存溢出,服務器會把長時間內沒有活躍的Session從內存刪除。這個時間就是Session的超時時間。如果超過了超時時間沒訪問過服務器,Session就自動失效了。
Token
1.什么是Token
token的意思是“令牌”,是服務端生成的一串字符串,作為客戶端進行請求的一個標識。
當用戶第一次登錄后,服務器生成一個token並將此token返回給客戶端,以后客戶端只需帶上這個token前來請求數據即可,無需再次帶上用戶名和密碼。
簡單token的組成;uid(用戶唯一的身份標識)、time(當前時間的時間戳)、sign(簽名,token的前幾位以哈希算法壓縮成的一定長度的十六進制字符串。為防止token泄露)。
2.Token的原理
- 用戶通過用戶名和密碼發送請求
- 程序校驗
- 程序返回一個Token給客戶端
- 客戶端存儲Token,並且每次發送請求攜帶Token
- 服務端驗證Token,並返回數據
3.Token的使用
Spring Boot和Jwt集成示例
項目依賴 pom.xml
<dependency>
<groupId>com.alibaba</groupId>
<artifactId>fastjson</artifactId>
<version>1.2.47</version>
</dependency>
<dependency>
<groupId>com.auth0</groupId>
<artifactId>java-jwt</artifactId>
<version>3.10.3</version>
</dependency>
自定義注解
//需要登錄才能進行操作的注解LoginToken
@Target({ElementType.METHOD, ElementType.TYPE})
@Retention(RetentionPolicy.RUNTIME)
public @interface LoginToken {
boolean required() default true;
}
//用來跳過驗證的PassToken
@Target({ElementType.METHOD, ElementType.TYPE})
@Retention(RetentionPolicy.RUNTIME)
public @interface PassToken {
boolean required() default true;
}
用戶實體類、及查詢service
public class User {
private String userID;
private String userName;
private String passWord;
public String getUserID() {
return userID;
}
public void setUserID(String userID) {
this.userID = userID;
}
public String getUserName() {
return userName;
}
public void setUserName(String userName) {
this.userName = userName;
}
public String getPassWord() {
return passWord;
}
public void setPassWord(String passWord) {
this.passWord = passWord;
}
}
@Service
public class UserService {
public User getUser(String userid, String password){
if ("admin".equals(userid) && "admin".equals(password)){
User user=new User();
user.setUserID("admin");
user.setUserName("admin");
user.setPassWord("admin");
return user;
}
else{
return null;
}
}
public User getUser(String userid){
if ("admin".equals(userid)){
User user=new User();
user.setUserID("admin");
user.setUserName("admin");
user.setPassWord("admin");
return user;
}
else{
return null;
}
}
}
Token生成
@Service
public class TokenService {
/**
* 過期時間10分鍾
*/
private static final long EXPIRE_TIME = 10 * 60 * 1000;
public String getToken(User user) {
Date date = new Date(System.currentTimeMillis() + EXPIRE_TIME);
String token="";
token= JWT.create().withAudience(user.getUserID()) // 將 user id 保存到 token 里面
.withExpiresAt(date) //十分鍾后token過期
.sign(Algorithm.HMAC256(user.getPassWord())); // 以 password 作為 token 的密鑰
return token;
}
}
攔截器攔截token
package com.jw.interceptor;
import com.auth0.jwt.JWT;
import com.auth0.jwt.JWTVerifier;
import com.auth0.jwt.algorithms.Algorithm;
import com.auth0.jwt.exceptions.JWTDecodeException;
import com.auth0.jwt.exceptions.JWTVerificationException;
import com.jw.annotation.LoginToken;
import com.jw.annotation.PassToken;
import com.jw.entity.User;
import com.jw.service.UserService;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.web.method.HandlerMethod;
import org.springframework.web.servlet.HandlerInterceptor;
import org.springframework.web.servlet.ModelAndView;
import javax.servlet.http.HttpServletRequest;
import javax.servlet.http.HttpServletResponse;
import java.lang.reflect.Method;
public class JwtInterceptor implements HandlerInterceptor{
@Autowired
private UserService userService;
@Override
public boolean preHandle(HttpServletRequest httpServletRequest, HttpServletResponse httpServletResponse, Object object) throws Exception {
String token = httpServletRequest.getHeader("token");// 從 http 請求頭中取出 token
// 如果不是映射到方法直接通過
if(!(object instanceof HandlerMethod)){
return true;
}
HandlerMethod handlerMethod=(HandlerMethod)object;
Method method=handlerMethod.getMethod();
//檢查是否有passtoken注釋,有則跳過認證
if (method.isAnnotationPresent(PassToken.class)) {
PassToken passToken = method.getAnnotation(PassToken.class);
if (passToken.required()) {
return true;
}
}
//檢查有沒有需要用戶權限的注解
if (method.isAnnotationPresent(LoginToken.class)) {
LoginToken loginToken = method.getAnnotation(LoginToken.class);
if (loginToken.required()) {
// 執行認證
if (token == null) {
throw new RuntimeException("無token,請重新登錄");
}
// 獲取 token 中的 user id
String userId;
try {
userId = JWT.decode(token).getAudience().get(0);
} catch (JWTDecodeException j) {
throw new RuntimeException("401");
}
User user = userService.getUser(userId);
if (user == null) {
throw new RuntimeException("用戶不存在,請重新登錄");
}
// 驗證 token
JWTVerifier jwtVerifier = JWT.require(Algorithm.HMAC256(user.getPassWord())).build();
try {
jwtVerifier.verify(token);
} catch (JWTVerificationException e) {
throw new RuntimeException("401");
}
return true;
}
}
return true;
}
@Override
public void postHandle(HttpServletRequest httpServletRequest, HttpServletResponse httpServletResponse, Object o, ModelAndView modelAndView) throws Exception {
}
@Override
public void afterCompletion(HttpServletRequest httpServletRequest, HttpServletResponse httpServletResponse, Object o, Exception e) throws Exception {
}
}
注冊攔截器
package com.jw.config;
import com.jw.interceptor.JwtInterceptor;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.web.servlet.config.annotation.InterceptorRegistry;
import org.springframework.web.servlet.config.annotation.WebMvcConfigurer;
@Configuration
public class InterceptorConfig implements WebMvcConfigurer{
@Override
public void addInterceptors(InterceptorRegistry registry) {
registry.addInterceptor(jwtInterceptor())
.addPathPatterns("/**"); // 攔截所有請求,通過判斷是否有 @LoginRequired 注解 決定是否需要登錄
//注冊TestInterceptor攔截器
// InterceptorRegistration registration = registry.addInterceptor(jwtInterceptor());
// registration.addPathPatterns("/**"); //添加攔截路徑
// registration.excludePathPatterns( //添加不攔截路徑
// "/**/*.html", //html靜態資源
// "/**/*.js", //js靜態資源
// "/**/*.css", //css靜態資源
// "/**/*.woff",
// "/**/*.ttf",
// "/swagger-ui.html"
// );
}
@Bean
public JwtInterceptor jwtInterceptor() {
return new JwtInterceptor();
}
}
登錄Controller
@RestController
public class LoginController {
@Autowired
private UserService userService;
@Autowired
private TokenService tokenService;
@PostMapping("login")
public Object login(String username, String password){
JSONObject jsonObject=new JSONObject();
User user=userService.getUser(username, password);
if(user==null){
jsonObject.put("message","登錄失敗!");
return jsonObject;
}else {
String token = tokenService.getToken(user);
jsonObject.put("token", token);
jsonObject.put("user", user);
return jsonObject;
}
}
@LoginToken
@GetMapping("/getMessage")
public String getMessage(){
return "你已通過驗證";
}
}
配置全局異常捕獲
@RestControllerAdvice
public class GlobalExceptionHandler {
@ResponseBody
@ExceptionHandler(Exception.class)
public Object handleException(Exception e) {
String msg = e.getMessage();
if (msg == null || msg.equals("")) {
msg = "服務器出錯";
}
JSONObject jsonObject = new JSONObject();
jsonObject.put("code", 1000);
jsonObject.put("message", msg);
return jsonObject;
}
}
postman測試
獲取token
無token登錄
有token登錄
錯誤token登錄
4.Token的優缺點
優點:
- 支持跨域訪問: Cookie是不允許垮域訪問的,token支持;
- 無狀態: token無狀態,session有狀態的;
- 去耦: 不需要綁定到一個特定的身份驗證方案。Token可以在任何地方生成,只要在 你的API被調用的時候, 你可以進行Token生成調用即可;
- 更適用於移動應用: Cookie不支持手機端訪問的;
- 性能: 在網絡傳輸的過程中,性能更好;
- 基於標准化: 你的API可以采用標准化的 JSON Web Token (JWT). 這個標准已經存在 多個后端庫(.NET, Ruby, Java,Python, PHP)和多家公司的支持(如: Firebase,Google, Microsoft)。
缺點:
- 占帶寬,正常情況下要比 session_id 更大,需要消耗更多流量,擠占更多帶寬,假如你的網站每月有 10 萬次的瀏覽器,就意味着要多開銷幾十兆的流量。聽起來並不多,但日積月累也是不小一筆開銷。實際上,許多人會在 JWT 中存儲的信息會更多;
- 無法在服務端注銷,那么久很難解決劫持問題;
- 性能問題,JWT 的賣點之一就是加密簽名,由於這個特性,接收方得以驗證 JWT 是否有效且被信任。但是大多數 Web 身份認證應用中,JWT 都會被存儲到 Cookie 中,這就是說你有了兩個層面的簽名。聽着似乎很牛逼,但是沒有任何優勢,為此,你需要花費兩倍的 CPU 開銷來驗證簽名。對於有着嚴格性能要求的 Web 應用,這並不理想,尤其對於單線程環境。
結尾
我是一個正在被打擊還在努力前進的碼農。如果文章對你有幫助,記得點贊、關注喲,謝謝!