登錄注冊(cè)與權(quán)限攔截完整實(shí)現(xiàn))
有段時(shí)間我?guī)团笥丫S護(hù)一個(gè)個(gè)人博客系統(tǒng)后臺(tái)管理特別原始——發(fā)文章要直接連數(shù)據(jù)庫(kù)往表里插用戶想登錄根本沒(méi)有賬號(hào)體系留言區(qū)就是個(gè)擺設(shè)。后來(lái)我實(shí)在看不下去用SpringBoot從零搭了一個(gè)JavaWeb博客系統(tǒng)把用戶登錄與注冊(cè)放在最前面做。原因不復(fù)雜沒(méi)有登錄注冊(cè)評(píng)論就掛不到人頭上后臺(tái)也沒(méi)有邊界任何訪客都能看到你想留給自己的草稿。這篇文章是這次實(shí)踐的一次完整復(fù)盤重點(diǎn)講清楚注冊(cè)怎么做、登錄會(huì)話怎么保持、未登錄用戶怎么攔截以及我在實(shí)測(cè)里踩過(guò)的三個(gè)真實(shí)坑。如果你剛學(xué)SpringBoot想拿博客系統(tǒng)當(dāng)練手項(xiàng)目或者在做其他JavaWeb管理系統(tǒng)這套思路可以直接抄。1. 開(kāi)工之前的冷靜思考博客系統(tǒng)為什么必須有登錄注冊(cè)1.1 四個(gè)躲不開(kāi)的理由身份、權(quán)限、歸屬與安全先想清楚一件事HTTP協(xié)議本身是無(wú)狀態(tài)的。瀏覽器請(qǐng)求一次頁(yè)面服務(wù)器處理完就把這個(gè)請(qǐng)求忘了下一次請(qǐng)求跟上次之間沒(méi)有天然關(guān)聯(lián)。所以博客這種需要區(qū)分“誰(shuí)是誰(shuí)”的系統(tǒng)必須通過(guò)某種機(jī)制把每次請(qǐng)求和用戶身份綁定起來(lái)。一個(gè)博客系統(tǒng)登錄注冊(cè)至少承擔(dān)四個(gè)任務(wù)身份識(shí)別管理員登錄后能進(jìn)后臺(tái)發(fā)文章普通用戶登錄后能評(píng)論、收藏系統(tǒng)得先知道當(dāng)前操作者是誰(shuí)。權(quán)限邊界不是所有頁(yè)面都對(duì)游客開(kāi)放。文章發(fā)布頁(yè)、用戶管理頁(yè)、設(shè)置頁(yè)沒(méi)有登錄的人不該看見(jiàn)更不該允許操作。數(shù)據(jù)歸屬評(píng)論表、文章表里要記錄作者和操作人。沒(méi)有登錄注冊(cè)評(píng)論就只能是匿名文本想查“某個(gè)人發(fā)過(guò)哪些評(píng)論”完全做不到。安全底線后臺(tái)操作必須有限制。哪怕是個(gè)人博客也不能讓陌生訪客改配置、刪文章。需求確認(rèn)以后功能邊界就清楚了注冊(cè)是允許外人成為系統(tǒng)用戶登錄是讓已有用戶重新進(jìn)入會(huì)話退出是清掉會(huì)話狀態(tài)。權(quán)限則通過(guò)會(huì)話狀態(tài)在攔截器里判斷。第一版不需要做太復(fù)雜但這條路要鋪好。1.2 技術(shù)選型為什么我選SpringBoot MyBatis Thymeleaf動(dòng)手前我在幾套方案里對(duì)比過(guò)純Servlet JSP能跑但配置繁瑣數(shù)據(jù)訪問(wèn)代碼要自己寫一大包開(kāi)發(fā)效率低。傳統(tǒng)SpringMVC單體項(xiàng)目比Servlet先進(jìn)但要配web.xml、配置類、打war包部署不夠輕。SpringBoot自動(dòng)裝配、內(nèi)嵌Tomcat、一個(gè)main方法就能啟動(dòng)生態(tài)成熟做JavaWeb博客系統(tǒng)很合適。我最終選的是SpringBoot 2.7 JDK8。有人覺(jué)得JDK8太老但很多畢業(yè)設(shè)計(jì)、公司老項(xiàng)目還在用兼容性穩(wěn)教程也好找。數(shù)據(jù)層我用MyBatis理由很實(shí)際SQL可控尤其是后面要聯(lián)表查用戶、文章、評(píng)論時(shí)MyBatis寫復(fù)雜查詢更直觀。模板引擎用Thymeleaf服務(wù)端渲染適合博客后臺(tái)這種傳統(tǒng)頁(yè)面場(chǎng)景也比前后端分離的頁(yè)面少處理一道跨域問(wèn)題。1.3 會(huì)話方案選型Session還是JWT怎么選登錄成功后服務(wù)器要記住“當(dāng)前請(qǐng)求是誰(shuí)”。最簡(jiǎn)單的就是Session用戶信息存在服務(wù)器內(nèi)存里瀏覽器端留一個(gè)JSessionID的Cookie。另一種是JWT把關(guān)鍵信息編碼進(jìn)一段簽名過(guò)的token由客戶端保存。對(duì)單體博客系統(tǒng)我偏向Session。理由有三點(diǎn)第一實(shí)現(xiàn)簡(jiǎn)單所有服務(wù)端渲染頁(yè)面都能直接讀Session第二注銷好辦調(diào)用invalidate()立刻讓會(huì)話失效第三博客的并發(fā)量級(jí)大概率不需要做無(wú)狀態(tài)集群。JWT更強(qiáng)的地方在前后端分離、微服務(wù)、多端共享登錄態(tài)但單體項(xiàng)目里硬上JWT還要自己處理續(xù)期、黑名單、密鑰管理得不償失。維度Session方案JWT方案存儲(chǔ)位置服務(wù)端內(nèi)存或共享存儲(chǔ)客戶端保存token服務(wù)端擴(kuò)展需要粘性會(huì)話或共享Redis天然無(wú)狀態(tài)易橫向擴(kuò)展注銷登錄立即銷毀session簡(jiǎn)單需要黑名單或短過(guò)期策略實(shí)現(xiàn)復(fù)雜度低容器幫你維護(hù)Cookie中需要處理簽名、過(guò)期適合場(chǎng)景單體、服務(wù)端渲染前后端分離、分布式系統(tǒng)這套項(xiàng)目場(chǎng)景就是單體服務(wù)端渲染所以直接Session不用猶豫。2. 用戶表設(shè)計(jì)登錄注冊(cè)的所有后續(xù)都從這里生長(zhǎng)2.1 字段清單與設(shè)計(jì)理由用戶表不用一開(kāi)始設(shè)計(jì)得很重但幾個(gè)核心字段必須有。我的表結(jié)構(gòu)字段如下id主鍵自增Long類型。username登錄用戶名唯一加上唯一索引。password存儲(chǔ)加密后的密碼密文注意長(zhǎng)度問(wèn)題。nickname展示昵稱允許為空為空時(shí)注冊(cè)邏輯里默認(rèn)用用戶名。email郵箱唯一索引為后續(xù)找回密碼、驗(yàn)證碼預(yù)留。avatar頭像地址存路徑字符串不放二進(jìn)制。role角色用字符串而不是布爾值因?yàn)橐院罂赡懿恢构芾韱T和普通用戶兩種角色。status賬號(hào)狀態(tài)1正常0禁用。第一版可能用不上但建議留否則以后想封禁賬號(hào)只能刪數(shù)據(jù)。create_time、update_time創(chuàng)建時(shí)間和更新時(shí)間幾乎所有表都需要。username長(zhǎng)度我設(shè)為30不是隨便選的。太短用戶取名不夠用太長(zhǎng)索引效率下降。郵箱設(shè)100足夠存絕大多數(shù)地址。昵稱和頭像都允許空減少注冊(cè)時(shí)的強(qiáng)制輸入。2.2 密碼字段長(zhǎng)度暗藏的坑BCrypt與varchar(60)這段一定要單獨(dú)說(shuō)因?yàn)楹芏嗳硕荚谶@里翻車。密碼字段最忌諱直接設(shè)成varchar(20)。BCrypt算法生成的密文固定是60個(gè)字符varchar(20)根本裝不下。我之前在一個(gè)項(xiàng)目里把password設(shè)成varchar(32)結(jié)果注冊(cè)時(shí)直接報(bào)Data too long for column password當(dāng)時(shí)一臉懵查了半天才發(fā)現(xiàn)是密文比字段長(zhǎng)。為什么不用MD5加鹽MD5哈希速度太快攻擊者可以用彩虹表、暴力窮舉快速撞庫(kù)。而且很多人的密碼是弱密碼不加鹽的MD5在彩虹表里直接就能查出來(lái)。BCrypt的特點(diǎn)是計(jì)算速度適合做口令哈希內(nèi)部自動(dòng)帶隨機(jī)鹽同樣的密碼每次加密結(jié)果都不同驗(yàn)證時(shí)用matches方法比對(duì)安全性好很多。所以建表時(shí)password字段最少是varchar(60)別省這30個(gè)字符。2.3 建表SQL與初始數(shù)據(jù)準(zhǔn)備建表SQL我直接貼出來(lái)CREATE TABLE blog_user ( id BIGINT(20) NOT NULL AUTO_INCREMENT, username VARCHAR(30) NOT NULL COMMENT 登錄用戶名, password VARCHAR(60) NOT NULL COMMENT BCrypt加密后的密碼, nickname VARCHAR(30) DEFAULT NULL COMMENT 展示昵稱, email VARCHAR(100) DEFAULT NULL COMMENT 郵箱, avatar VARCHAR(200) DEFAULT NULL COMMENT 頭像地址, role VARCHAR(20) NOT NULL DEFAULT ROLE_USER COMMENT 角色, status TINYINT(4) NOT NULL DEFAULT 1 COMMENT 狀態(tài)1正常 0禁用, create_time DATETIME NOT NULL, update_time DATETIME NOT NULL, PRIMARY KEY (id), UNIQUE KEY uk_username (username), KEY idx_email (email) ) ENGINEInnoDB DEFAULT CHARSETutf8mb4 COMMENT博客用戶表;管理員賬號(hào)不建議在SQL里直接insert明文密碼。正確做法是先寫一小段Java代碼調(diào)用BCryptPasswordEncoder生成一個(gè)哈希值再把這個(gè)哈希值手動(dòng)插入數(shù)據(jù)庫(kù)。這樣即使有人拿到數(shù)據(jù)庫(kù)備份也沒(méi)法知道明文密碼。郵箱字段我給它普通索引而不是唯一索引是因?yàn)榈谝话婵赡苡腥瞬惶钹]箱唯一索引會(huì)直接拒絕多個(gè)空值。3. 注冊(cè)功能實(shí)現(xiàn)從表單提交到密碼入庫(kù)3.1 前端注冊(cè)表單字段、校驗(yàn)與提示注冊(cè)表單字段我控制在五個(gè)用戶名、密碼、確認(rèn)密碼、郵箱、昵稱。昵稱和郵箱可以選填但用戶名、密碼、確認(rèn)密碼必填。Thymeleaf模板里我推薦用th:object綁定到RegisterForm對(duì)象每個(gè)輸入框用th:field對(duì)應(yīng)字段這樣出校驗(yàn)錯(cuò)誤時(shí)用戶填過(guò)的內(nèi)容能回顯不會(huì)因?yàn)橐淮五e(cuò)誤全部清空。form th:action{/auth/doRegister} th:object${registerForm} methodpost label用戶名/label input typetext th:field*{username} placeholder3-20個(gè)字符 / label密碼/label input typepassword th:field*{password} placeholder6-32個(gè)字符 / label確認(rèn)密碼/label input typepassword th:field*{confirmPassword} placeholder再輸入一次密碼 / label郵箱選填/label input typetext th:field*{email} placeholder用于找回密碼 / label昵稱選填/label input typetext th:field*{nickname} placeholder默認(rèn)顯示用戶名 / button typesubmit注冊(cè)/button p classerror th:if${errorMsg} th:text${errorMsg}/p /form前端校驗(yàn)只有體驗(yàn)價(jià)值比如密碼位數(shù)、兩次密碼是否一致可以用HTML5的required和JavaScript來(lái)做。但后端必須再完整校驗(yàn)一遍因?yàn)檎?qǐng)求完全可以繞過(guò)頁(yè)面直接發(fā)過(guò)來(lái)。3.2 Controller層接收請(qǐng)求與參數(shù)校驗(yàn)Controller這里有兩個(gè)方法GET /auth/register負(fù)責(zé)渲染注冊(cè)頁(yè)P(yáng)OST /auth/doRegister負(fù)責(zé)接收提交。Controller RequestMapping(/auth) public class AuthController { private final UserService userService; public AuthController(UserService userService) { this.userService userService; } GetMapping(/register) public String registerPage(Model model) { if (!model.containsAttribute(registerForm)) { model.addAttribute(registerForm, new RegisterForm()); } return auth/register; } PostMapping(/doRegister) public String doRegister(Validated ModelAttribute(registerForm) RegisterForm form, BindingResult bindingResult, Model model) { if (bindingResult.hasErrors()) { return auth/register; } try { userService.register(form); } catch (BizException e) { model.addAttribute(errorMsg, e.getMessage()); return auth/register; } return redirect:/auth/login?registered; } }這里的RegisterForm是一個(gè)專門接收表單參數(shù)的DTO類不是數(shù)據(jù)庫(kù)實(shí)體。字段上直接加校驗(yàn)注解public class RegisterForm { NotBlank(message 用戶名不能為空) Size(min 3, max 20, message 用戶名長(zhǎng)度需在3到20之間) private String username; NotBlank(message 密碼不能為空) Size(min 6, max 32, message 密碼長(zhǎng)度需在6到32之間) private String password; NotBlank(message 確認(rèn)密碼不能為空) private String confirmPassword; Email(message 郵箱格式不正確) private String email; private String nickname; // getter / setter 省略 }注意Validated注解配上BindingResult是固定組合參數(shù)校驗(yàn)錯(cuò)誤會(huì)直接進(jìn)到后面的邏輯。另外我不建議在Controller里寫業(yè)務(wù)判斷比如“密碼是否一致”“用戶名是否存在”這些應(yīng)該下沉到Service層Controller只做參數(shù)接收、錯(cuò)誤處理和路由跳轉(zhuǎn)。3.3 Service層核心邏輯唯一性檢查、BCrypt加密、落庫(kù)注冊(cè)的核心在Service里。我先說(shuō)一個(gè)關(guān)鍵決定密碼加密只用spring-security-crypto這一個(gè)依賴沒(méi)有引入完整Spring Security。完整的Security框架默認(rèn)帶過(guò)濾器鏈它會(huì)接管登錄認(rèn)證我自定義的登錄邏輯還沒(méi)執(zhí)行就被它搶走了調(diào)試起來(lái)很痛苦。只引入crypto模塊只借它的加密工具最干凈。dependency groupIdorg.springframework.security/groupId artifactIdspring-security-crypto/artifactId /dependency然后注冊(cè)一個(gè)PasswordEncoder的BeanConfiguration public class AppConfig { Bean public PasswordEncoder passwordEncoder() { return new BCryptPasswordEncoder(); } }Service代碼Service public class UserService { private final UserMapper userMapper; private final PasswordEncoder passwordEncoder; public UserService(UserMapper userMapper, PasswordEncoder passwordEncoder) { this.userMapper userMapper; this.passwordEncoder passwordEncoder; } public void register(RegisterForm form) { String username form.getUsername().trim(); String email form.getEmail() null ? : form.getEmail().trim(); if (userMapper.countByUsername(username) 0) { throw new BizException(用戶名已被占用換一個(gè)試試); } if (StringUtils.hasText(email) userMapper.countByEmail(email) 0) { throw new BizException(該郵箱已經(jīng)注冊(cè)過(guò)了); } if (!form.getPassword().equals(form.getConfirmPassword())) { throw new BizException(兩次輸入的密碼不一致); } BlogUser user new BlogUser(); user.setUsername(username); user.setPassword(passwordEncoder.encode(form.getPassword())); user.setNickname(StringUtils.hasText(form.getNickname()) ? form.getNickname().trim() : username); user.setEmail(email); user.setRole(ROLE_USER); user.setStatus(1); user.setCreateTime(new Date()); user.setUpdateTime(new Date()); userMapper.insert(user); } }幾個(gè)細(xì)節(jié)username和email都做trim防止用戶不小心前后空格導(dǎo)致“明明輸入的是abc庫(kù)里存的是‘ abc ’”下次登錄死活對(duì)不上。業(yè)務(wù)異常用自定義BizException處理Controller統(tǒng)一展示message不用在每個(gè)接口寫錯(cuò)誤分發(fā)。密碼一致性的判斷放在Service不依賴前端。BCryptPasswordEncoder.encode()每次生成的密文都不同這是特性不是bug。驗(yàn)證時(shí)用matches(rawPassword, encodedPassword)。注冊(cè)成功以后我用的是“POST后重定向到登錄頁(yè)”這是PRG模式Post/Redirect/Get。這樣做的好處很明顯用戶注冊(cè)成功后刷新登錄頁(yè)不會(huì)觸發(fā)表單重復(fù)提交瀏覽器地址欄也變成登錄頁(yè)而不是停留在POST請(qǐng)求。這是頁(yè)面開(kāi)發(fā)里很值得養(yǎng)成的習(xí)慣。4. 登錄功能實(shí)現(xiàn)從密碼比對(duì)到會(huì)話寫入4.1 登錄表單與處理接口登錄頁(yè)相對(duì)簡(jiǎn)單。表單提交到 /auth/doLogin需要用戶名和密碼兩個(gè)參數(shù)。GET /auth/login負(fù)責(zé)渲染登錄頁(yè)同時(shí)接收一個(gè)registered參數(shù)處理“注冊(cè)成功”的提示。form th:action{/auth/doLogin} methodpost label用戶名/label input typetext nameusername / label密碼/label input typepassword namepassword / button typesubmit登錄/button p classerror th:if${errorMsg} th:text${errorMsg}/p /formControllerGetMapping(/login) public String loginPage(RequestParam(value registered, required false) String registered, Model model) { if (StringUtils.hasText(registered)) { model.addAttribute(tipMsg, 注冊(cè)成功請(qǐng)登錄); } return auth/login; }4.2 認(rèn)證邏輯統(tǒng)一提示“用戶名或密碼錯(cuò)誤”登錄認(rèn)證放在Service里controller只負(fù)責(zé)取session和跳轉(zhuǎn)。Service的認(rèn)證方法public BlogUser authenticate(String username, String rawPassword) { String name username.trim(); BlogUser user userMapper.findByUsername(name); if (user null || !passwordEncoder.matches(rawPassword, user.getPassword())) { return null; } return user; }這里要注意錯(cuò)誤提示統(tǒng)一用“用戶名或密碼錯(cuò)誤”不要分別提示“用戶名不存在”“密碼錯(cuò)了”。區(qū)別背后的邏輯是防止用戶名枚舉如果系統(tǒng)對(duì)不存在的用戶名和錯(cuò)誤的密碼返回不同信息攻擊者就可以通過(guò)不斷嘗試判斷出哪些用戶名是真實(shí)注冊(cè)過(guò)的。還有一個(gè)安全細(xì)節(jié)是時(shí)間差。用戶不存在時(shí)直接跳過(guò)密碼比較用戶存在但密碼錯(cuò)誤時(shí)BCrypt的matches要額外算一次這兩者的響應(yīng)時(shí)間會(huì)有差異。對(duì)小型博客系統(tǒng)來(lái)說(shuō)一般不用處理但如果你在做安全要求高的登錄接口可以在用戶不存在時(shí)也執(zhí)行一次假的matches讓響應(yīng)時(shí)間接近避免被時(shí)序探測(cè)。登錄的Controller寫法PostMapping(/doLogin) public String doLogin(RequestParam String username, RequestParam String password, HttpSession session, RedirectAttributes ra) { BlogUser user userService.authenticate(username, password); if (user null) { ra.addFlashAttribute(errorMsg, 用戶名或密碼錯(cuò)誤請(qǐng)重新輸入); return redirect:/auth/login; } if (user.getStatus() ! 1) { ra.addFlashAttribute(errorMsg, 該賬號(hào)已被禁用請(qǐng)聯(lián)系管理員); return redirect:/auth/login; } session.setAttribute(Constants.SESSION_USER, user); return redirect:/admin/index; }狀態(tài)為0的賬號(hào)即使密碼正確也不能登錄。這個(gè)判斷放在密碼比對(duì)之后能給攻擊者多一點(diǎn)信息但一般項(xiàng)目不需要過(guò)度隱藏提示“被禁用”更人性化。4.3 登錄成功后的Session寫入、頁(yè)面聯(lián)動(dòng)與退出清理登錄成功后把用戶對(duì)象整體放入Sessionsession.setAttribute(Constants.SESSION_USER, user);Session有效時(shí)間我在配置里統(tǒng)一設(shè)置server: servlet: session: timeout: 30m服務(wù)端渲染頁(yè)面可以直接判斷Session里的loginUser來(lái)顯示不同導(dǎo)航。Thymeleaf模板里這樣寫div th:if${session.loginUser} ! null span th:text${session.loginUser.nickname}/span a th:href{/logout}退出/a /div div th:unless${session.loginUser} ! null a th:href{/auth/login}登錄/a a th:href{/auth/register}注冊(cè)/a /div退出登錄我強(qiáng)烈建議直接調(diào)用session.invalidate()而不是只移除loginUser屬性GetMapping(/logout) public String logout(HttpSession session) { session.invalidate(); return redirect:/auth/login; }invalidate()會(huì)讓整個(gè)Session失效舊的JSessionID也不能繼續(xù)使用。這能降低Session固定攻擊風(fēng)險(xiǎn)。攻擊者如果先給用戶一個(gè)已知的Session ID用戶登錄成功后這個(gè)Session如果沒(méi)有重建攻擊者就可能拿到登錄態(tài)。主動(dòng)銷毀再重建會(huì)話是個(gè)窮人版的安全加固。5. 攔截器與權(quán)限邊緣未登錄用戶不該看到的后臺(tái)頁(yè)面5.1 攔截器的判斷邏輯與細(xì)節(jié)登錄狀態(tài)只寫入Session還不夠必須用到“需要保護(hù)”的路徑上。這里我選擇實(shí)現(xiàn)HandlerInterceptor。public class LoginInterceptor implements HandlerInterceptor { Override public boolean preHandle(HttpServletRequest request, HttpServletResponse response, Object handler) throws Exception { HttpSession session request.getSession(false); Object loginUser session null ? null : session.getAttribute(Constants.SESSION_USER); if (loginUser ! null) { return true; } response.sendRedirect(request.getContextPath() /auth/login); return false; } }注意我用了request.getSession(false)不是getSession()。getSession(false)表示“如果當(dāng)前沒(méi)有Session就返回null不要新建”。用getSession(true)會(huì)有個(gè)壞處每個(gè)未登錄用戶訪問(wèn)公開(kāi)頁(yè)面服務(wù)器都會(huì)創(chuàng)建一個(gè)空Session日志里Session數(shù)量暴漲響應(yīng)頭也會(huì)多出Set-Cookie。沒(méi)必要的資源浪費(fèi)。5.2 放行策略哪些路徑必須放行攔截器注冊(cè)到Spring MVC里通過(guò)配置類控制路徑Configuration public class WebConfig implements WebMvcConfigurer { Override public void addInterceptors(InterceptorRegistry registry) { registry.addInterceptor(new LoginInterceptor()) .addPathPatterns(/admin/**, /user/center/**, /comment/add, /comment/delete/**) .excludePathPatterns( /auth/login, /auth/doLogin, /auth/register, /auth/doRegister, /css/**, /js/**, /images/**, /error ); } }我特意沒(méi)有攔截/**而是只攔截后臺(tái)和操作型路徑。博客首頁(yè)、文章詳情頁(yè)、文章列表頁(yè)都應(yīng)該對(duì)外開(kāi)放否則搜索引擎都進(jìn)不來(lái)。這里路徑設(shè)計(jì)上有個(gè)經(jīng)驗(yàn)把“讀取評(píng)論列表”和“提交評(píng)論”分開(kāi)路徑列表路徑不攔截提交路徑攔截這樣游客能看評(píng)論但提交評(píng)論必須先登錄。放行配置有三個(gè)容易漏的地方登錄和注冊(cè)的POST提交接口也要放行否則用戶提交登錄表單時(shí)又被攔截陷入死循環(huán)。靜態(tài)資源必須放行登錄頁(yè)沒(méi)樣式瀏覽器加載不了css/js頁(yè)面看起來(lái)像裸奔。/error路徑建議放行避免異常跳轉(zhuǎn)遇到攔截器造成二次故障。5.3 無(wú)限重定向問(wèn)題與排查思路這是登錄注冊(cè)里最常見(jiàn)的連環(huán)坑你寫了攔截器但忘記把登錄頁(yè)路徑放到excludePathPatterns里。未登錄用戶請(qǐng)求后臺(tái)被攔截器重定向到/auth/login但/auth/login也在攔截范圍內(nèi)于是再次被重定向到/auth/login瀏覽器地址欄不斷刷同一個(gè)地址頁(yè)面一片空白。排查方法打開(kāi)瀏覽器開(kāi)發(fā)者工具看Network面板會(huì)看到/auth/login被重復(fù)請(qǐng)求。如果自己寫的攔截器連登錄頁(yè)面都攔住十有八九是放行路徑漏了。另一種表現(xiàn)是帶context-path時(shí)重定向地址少了一段導(dǎo)致404。所以我習(xí)慣在sendRedirect里加request.getContextPath()不是直接寫“/auth/login”。如果是Spring Boot項(xiàng)目完整路徑應(yīng)該是“/項(xiàng)目前綴/auth/login”。6. 實(shí)測(cè)中踩過(guò)的三個(gè)真實(shí)坑6.1 密碼字段長(zhǎng)度不足注冊(cè)直接數(shù)據(jù)庫(kù)報(bào)錯(cuò)這個(gè)坑我前面提醒過(guò)再展開(kāi)說(shuō)說(shuō)完整的排查鏈路?,F(xiàn)象注冊(cè)頁(yè)面一提交控制臺(tái)直接拋SQLException報(bào)錯(cuò)內(nèi)容類似Data too long for column password at row 1。排查步驟先看異常定位到userMapper.insert方法。打印要插入的BlogUser對(duì)象發(fā)現(xiàn)password這個(gè)屬性值是一長(zhǎng)串“$2a$10$……”字符串。數(shù)了一下長(zhǎng)度恰好在60個(gè)字符左右。去看表結(jié)構(gòu)password字段是varchar(32)當(dāng)場(chǎng)破案。解決把password字段改成varchar(60)。以后凡是遇到“Data too long”第一件事是數(shù)一數(shù)實(shí)際寫入的值到底多長(zhǎng)再?zèng)Q定字段長(zhǎng)度而不是憑感覺(jué)猜。6.2 中文亂碼編碼配置與模板聲明不一致現(xiàn)象提交注冊(cè)表單后用戶名里的中文全部變成問(wèn)號(hào)數(shù)據(jù)庫(kù)里存的也是亂碼。這個(gè)問(wèn)題有三個(gè)環(huán)節(jié)可能出問(wèn)題HTML頁(yè)面本身不是UTF-8編碼HTTP請(qǐng)求/響應(yīng)的字符編碼沒(méi)有強(qiáng)制UTF-8數(shù)據(jù)庫(kù)連接URL或表字段字符集不是UTF-8。我的解決方式server: servlet: encoding: charset: UTF-8 force: true數(shù)據(jù)庫(kù)連接URL加上參數(shù)jdbc:mysql://localhost:3306/blog_system?useUnicodetruecharacterEncodingutf8serverTimezoneAsia/Shanghai還有HTML頁(yè)面的meta標(biāo)簽也要帶上charsetUTF-8Thymeleaf默認(rèn)沒(méi)問(wèn)題但如果你用其他模板引擎很容易漏。實(shí)際項(xiàng)目中我最常漏的是第二項(xiàng)spring-boot-starter-web默認(rèn)已經(jīng)配了UTF-8但force: true會(huì)強(qiáng)制覆蓋請(qǐng)求和響應(yīng)頭加了以后亂碼幾率大幅下降。6.3 重定向后錯(cuò)誤提示消失Flash屬性用法新手很容易寫這樣的代碼model.addAttribute(errorMsg, 用戶名或密碼錯(cuò)誤); return redirect:/auth/login;然后登錄頁(yè)拿不到errorMsg。原因是Redirect是一次全新的GET請(qǐng)求原來(lái)的Model數(shù)據(jù)根本不會(huì)帶過(guò)去它們消失在第一次請(qǐng)求里了。正確方式是用RedirectAttributes.addFlashAttributera.addFlashAttribute(errorMsg, 用戶名或密碼錯(cuò)誤); return redirect:/auth/login;FlashMap機(jī)制會(huì)在重定向前把數(shù)據(jù)臨時(shí)放到Session里重定向后的第一次請(qǐng)求能讀取并立即銷毀。注意不要用它傳大量數(shù)據(jù)它本身就不是給你做參數(shù)傳遞用的只適合放提示信息、跳轉(zhuǎn)消息這類輕量數(shù)據(jù)。如果你要傳業(yè)務(wù)對(duì)象老老實(shí)實(shí)用query參數(shù)或Session。7. 上線之前還能做的加固與擴(kuò)展7.1 驗(yàn)證碼與失敗次數(shù)限制成本低見(jiàn)效快注冊(cè)接口是垃圾腳本最愛(ài)刷的入口圖形驗(yàn)證碼是第一道便宜的防線。簡(jiǎn)單做法后端生成一個(gè)四位隨機(jī)碼把答案存Session用圖片渲染出來(lái)。前端表單提交時(shí)帶上用戶輸入的驗(yàn)證碼后端比對(duì)。不引入第三方組件也可以用Java自帶的BufferedImage畫一張帶干擾線的圖片即可。生成驗(yàn)證碼、存Session、比對(duì)的邏輯自己實(shí)現(xiàn)起來(lái)不難。登錄接口另一個(gè)必做項(xiàng)是失敗次數(shù)限制。我在項(xiàng)目里用內(nèi)存Map存“IP用戶名”級(jí)別的失敗記錄MapString, FailInfo failMap new ConcurrentHashMap(); // 每次登錄失敗時(shí) FailInfo info failMap.computeIfAbsent(key, k - new FailInfo()); info.failCount; info.lastTime System.currentTimeMillis(); if (info.failCount 5 now - info.lastTime 5 * 60 * 1000) { throw new BizException(失敗次數(shù)過(guò)多請(qǐng)5分鐘后再試); } // 登錄成功時(shí)刪除該key這塊有幾個(gè)細(xì)節(jié)只按IP鎖一個(gè)出口IP下的多個(gè)用戶會(huì)被連帶影響只按用戶名鎖攻擊者可以通過(guò)持續(xù)失敗把某個(gè)真實(shí)用戶的賬號(hào)直接鎖掉。所以取“IP用戶名”組合兩邊都限制誤傷概率小很多。單機(jī)部署用內(nèi)存Map沒(méi)問(wèn)題以后部署多實(shí)例再遷到Redis。7.2 從單角色到RBAC的演進(jìn)第一版用戶表只有role一個(gè)字符串字段足夠區(qū)分管理員和普通用戶。但博客系統(tǒng)如果以后加編輯、審稿人、運(yùn)營(yíng)等角色字符串字段會(huì)越寫越亂。演進(jìn)方向是經(jīng)典的RBAC模型用戶表、角色表、權(quán)限表以及用戶-角色、角色-權(quán)限兩張關(guān)聯(lián)表。登錄成功后查當(dāng)前用戶有哪些角色再匯總所有角色對(duì)應(yīng)的權(quán)限集合放在Session或權(quán)限框架里。這樣每個(gè)后端接口只要校驗(yàn)?zāi)硞€(gè)權(quán)限點(diǎn)不用關(guān)心用戶具體是什么角色。我不建議一上來(lái)就引入完整Spring Security做權(quán)限。先把自研登錄注冊(cè)、Session、攔截器這套走通你會(huì)對(duì)認(rèn)證和授權(quán)有更直觀的理解以后真要引入框架也清楚它替你做了什么。7.3 登錄審計(jì)與“記住我”功能登錄審計(jì)這個(gè)習(xí)慣我從第二次做項(xiàng)目才開(kāi)始補(bǔ)齊。實(shí)現(xiàn)很輕在doLogin成功之后往login_log表插一條記錄包含用戶ID、登錄時(shí)間、登錄IP、UserAgent。單用戶博客看不出多大作用但多用戶系統(tǒng)里出問(wèn)題要定位“誰(shuí)在什么時(shí)間干了什么”時(shí)這條記錄就是救命稻草。“記住我”功能則是另一個(gè)常見(jiàn)訴求。實(shí)現(xiàn)思路是用隨機(jī)token存Cookie同時(shí)把token的哈希和過(guò)期時(shí)間存到數(shù)據(jù)庫(kù)。用戶帶著token回來(lái)時(shí)用token找到用戶并重建Session。這里我特別要提一個(gè)反面教訓(xùn)之前為了圖省事想過(guò)直接把用戶ID放進(jìn)Cookie甚至用明文用戶名和密碼放Cookie這種做法一旦Cookie泄露等于把賬號(hào)雙手奉上。存隨機(jī)token、存哈希、設(shè)過(guò)期、用戶退出時(shí)刪除每一步都不能省。安全這件事從第一版就按正確的路徑做后面維護(hù)會(huì)省很多事。