手擼一個登陸認證功能

登錄認證,估計是所有系統中最常見的功能了,並且也是最基礎、最重要的功能。爲了做好這一塊而誕生了許多安全框架,比如最常見的 Shiro、Spring Security 等。

本文是一個系列文章,最終的目的是想與大家分享在實際項目中如何運用安全框架完成登錄認證(Authentication)、權限授權(Authorization)等功能。

只不過一上來就講框架的配置和使用我覺得並不好,一是對於新手來說會很懵逼,二是不利於大家對框架的深入理解。所以本文先寫 手擼登錄認證基本功能,下一篇文章再寫 不用安全框架手擼權限授權,最後再寫 如何運用安全框架整合這些功能。

本文會從最簡單、最基礎的講解起,新手可放心食用。讀完文章你能收穫:

本文所有代碼全部放在了 Github 上,clone 下來即可運行查看效果。

基礎知識

登錄認證(Authorization)的概念非常簡單,就是通過一定手段對用戶的身份進行確認。

確認這還不容易?就是判斷用戶的賬號和密碼是否正確嘛,if、else 搞定。沒錯,這的確很容易,但是確認過後呢?要知道在 web 系統中有一個重要的概念就是:HTTP 請求是一個無狀態的協議。就是說瀏覽器每一次發送的請求都是獨立的,對於服務器來說你每次的請求都是 “新客”,它不記得你曾經有沒有來過。舉一個例子大家就知道了:

無狀態,也可以叫作無記憶,服務器不會記得你之前做了什麼,它只會看到你當前的請求。所以,在 Web 系統中確認了用戶的身份後,還需要有種機制來記住這個用戶已經登錄過了,不然用戶每一次操作都要輸入賬號密碼,那這系統也沒法用了!

那怎樣才能讓服務器記住你的登錄狀態呢?那就是憑證!登錄之後每一次請求都攜帶一個登錄憑證來告訴服務器我是誰,這樣纔能有以下的效果:

B:味道不錯。

現在流行兩種方式登錄認證方式:SessionJWT,無論是哪種方式其原理都是 Token 機制,即保存憑證:

  1. 前端發起登錄認證請求;

  2. 後端登錄驗證通過,返回給前端一個憑證

  3. 前端發起新的請求時攜帶憑證

接下來我們就上代碼,用這兩種方式分別實現登錄認證功能。

實現

我們使用 SpringBoot 來搭建 Web 項目,只需導入 Web 項目依賴:

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

我們再建一個實體類用來模擬用戶:

public class User{
private String username;
private String password;
}

Session

Session 是一種有狀態的會話管理機制,其目的就是爲了解決 HTTP 無狀態請求帶來的問題。

當用戶登錄認證請求通過時,服務端會將用戶的信息存儲起來,並生成一個 Session Id 發送給前端,前端將這個 Session Id 保存起來(一般是保存在 Cookie 中)。之後前端再發送請求時都攜帶 Session Id,服務器端再根據這個 Session Id 來檢查該用戶有沒有登錄過:

基本功能

接下來我們就用代碼來實現具體功能,非常簡單,我們只需要在用戶登錄的時候將用戶信息存在 HttpSession 中就完成了:

@RestController
public class SessionController {
@PostMapping("login")
public String login(@RequestBody User user, HttpSession session) {
// 判斷賬號密碼是否正確,這一步肯定是要讀取數據庫中的數據來進行校驗的,這裏爲了模擬就省去了
if ("admin".equals(user.getUsername()) && "admin".equals(user.getPassword())) {
// 正確的話就將用戶信息存到session中
            session.setAttribute("user", user);
return "登錄成功";
        }
return "賬號或密碼錯誤";
    }
@GetMapping("/logout")
public String logout(HttpSession session) {
// 退出登錄就是將用戶信息刪除
        session.removeAttribute("user");
return "退出成功";
    }
}

在後續會話中,用戶訪問其他接口就可以檢查用戶是否已經登錄認證:

@GetMapping("api")
public String api(HttpSession session) {
// 模擬各種api,訪問之前都要檢查有沒有登錄,沒有登錄就提示用戶登錄
    User user = (User) session.getAttribute("user");
if (user == null) {
return "請先登錄";
    }
// 如果有登錄就調用業務層執行業務邏輯,然後返回數據
return "成功返回數據";
}
@GetMapping("api2")
public String api2(HttpSession session) {
// 模擬各種api,訪問之前都要檢查有沒有登錄,沒有登錄就提示用戶登錄
    User user = (User) session.getAttribute("user");
if (user == null) {
return "請先登錄";
    }
// 如果有登錄就調用業務層執行業務邏輯,然後返回數據
return "成功返回數據";
}

我們現在來測試一下效果,先不登錄調用一下其他接口看看:

可以看到是調用失敗的,那我們再進行登錄:

登錄成功後我們再調用剛纔的接口:

這樣就完成了基本的登錄功能!是不是相當簡單?

之前說過保持登錄的核心就是憑證,可上面的代碼也沒看到傳遞憑證的過程呀,這是因爲這些工作 Servlet 都幫我們做好了!

如果用戶第一次訪問某個服務器時,服務器響應數據時會在響應頭的 Set-Cookie 標識裏將 Session Id 返回給瀏覽器,瀏覽器就將標識中的數據存在 Cookie 中:

瀏覽器後續訪問服務器就會攜帶 Cookie:

每一個 Session Id 都對應一個 HttpSession 對象,然後服務器就根據你這個 HttpSession 對象來檢測你這個客戶端是否已經登錄了,也就是剛纔代碼裏演示的那樣。

有人可能會問,前後端分離一般都是用 Ajax 跨域請求後端數據,怎麼攜帶 cookie 呢?這個很簡單,只需要 Ajax 請求時設置 withCredentials=true 就可以跨域攜帶 cookie 信息了。

過濾器

完成了基本的登錄認證後我們再加強一下功能!除了登錄接口外,我們其他接口都要在 Controller 層裏做登錄判斷,這太麻煩了。我們完全可以對每個接口過濾攔截一下,判斷有沒有登錄,如果沒有登錄就直接結束請求,登錄了才放行。這裏我們通過過濾器來實現:

@Component
public class LoginFilter extends OncePerRequestFilter {
@Override
protected void doFilterInternal(HttpServletRequest request, HttpServletResponse response, FilterChain filterChain) throws ServletException, IOException {
// 簡單的白名單,登錄這個接口直接放行
if ("/login".equals(request.getRequestURI())) {
            filterChain.doFilter(request, response);
return;
        }
// 已登錄就放行
        User user = (User) request.getSession().getAttribute("user");
if (user != null) {
            filterChain.doFilter(request, response);
return;
        }
// 走到這裏就代表是其他接口,且沒有登錄
// 設置響應數據類型爲json(前後端分離)
        response.setContentType("application/json;charset=utf-8");
        PrintWriter out = response.getWriter();
// 設置響應內容,結束請求
out.write("請先登錄");
out.flush();
out.close();
    }
}

這時我們 Controller 層就可以去除多餘的登錄判斷邏輯了:

@GetMapping("api")
public String api() {
return "api成功返回數據";
}
@GetMapping("api2")
public String api2() {
return "api2成功返回數據";
}

重啓服務後我們再調用一下登錄接口看下效果:

過濾器生效了!

上下文對象

在有些情況下,就算加了過濾器後我們現在還不能在 Controller 層將 Session 代碼去掉!因爲在實際業務中對用戶對象操作是非常常見的,而我們的業務代碼一般都寫在 Service 業務層,那麼我們 Service 層想要操作用戶對象還得從 Controller 那傳參過來,就像這樣:

@GetMapping("api")
public String api(HttpSession session) {
    User user = (User) session.getAttribute("user");
// 將用戶對象傳遞給Service層
    userService.doSomething(user);
return "成功返回數據";
}

每個接口都要這麼寫太麻煩了,有沒有什麼辦法可以讓我直接在 Service 層獲取到用戶對象呢?當然是可以的。我們可以通過 SpringMVC 提供的 RequestContextHolder 對象在程序任何地方獲取到當前請求對象,從而獲取我們保存在 HttpSession 中的用戶對象。我們可以寫一個上下文對象來實現該功能:

public class RequestContext {
public static HttpServletRequest getCurrentRequest() {
// 通過`RequestContextHolder`獲取當前request請求對象
return ((ServletRequestAttributes) (RequestContextHolder.currentRequestAttributes())).getRequest();
    }
public static User getCurrentUser() {
// 通過request對象獲取session對象,再獲取當前用戶對象
return (User)getCurrentRequest().getSession().getAttribute("user");
    }
}

然後我們在 Service 層直接調用我們寫的方法就可以獲取到用戶對象啦:

public void doSomething() {
    User user = RequestContext.getCurrentUser();
    System.out.println("service層---當前登錄用戶對象:" + user);
}

我們再在 Controller 層直接調用 Service:

@GetMapping("api")
public String api() {
// 各種業務操作
    userService.doSomething();
return "api成功返回數據";
}

這樣一套做好之後,看下前端成功調用 API 接口時的效果:

Service 層成功獲取上下文對象!

JWT

除了 Session 之外,目前比較流行的做法就是使用 JWT(JSON Web Token)。關於 JWT 網上有很多講解資料,一個工具而已會用就行,所以在這裏我就不過多解釋這玩意了,大家只需要知道這兩點就行:

  1. 可以將一段數據加密成一段字符串,也可以從這字符串解密回數據;

  2. 可以對這個字符串進行校驗,比如有沒有過期,有沒有被篡改。

有兩上面兩個特性之後就可以用來做登錄認證了。當用戶登錄成功的時候,服務器生成一個 JWT 字符串返回給瀏覽器,瀏覽器將 JWT 保存起來,在之後的請求中都攜帶上 JWT,服務器再對這個 JWT 進行校驗,校驗通過的話就代表這個用戶登錄了:

咦!這不和 Session 一樣嘛,就是把 Session Id 換成了 JWT 字符串而已,這圖啥啊。

沒錯,整體流程來說是一樣的,我之前也說了,無論哪種方式其核心都是 Token 機制。但 Session 和 JWT 有一個重要的區別,就是 Session 是有狀態的,JWT 是無狀態的

說人話就是,Session 在服務端保存了用戶信息,而 JWT 在服務端沒有保存任何信息。當前端攜帶 Session Id 到服務端時,服務端要檢查其對應的 HttpSession 中有沒有保存用戶信息,保存了就代表登錄了。當使用 JWT 時,服務端只需要對這個字符串進行校驗,校驗通過就代表登錄了。

至於這兩種方式各有什麼好處和壞處先彆着急討論,咱先將 JWT 用起來!兩者的優缺點文章最後會做講解滴。

基本功能

要用到 JWT 先要導入一個依賴項:

<dependency>
<groupId>io.jsonwebtoken</groupId>
<artifactId>jjwt</artifactId>
<version>0.9.1</version>
</dependency>

爲了方便使用,我們先寫一個 JWT 的工具類,工具類就提供兩個方法一個生成一個解析 :

public final class JwtUtil {
/**
     * 這個祕鑰是防止JWT被篡改的關鍵,隨便寫什麼都好,但決不能泄露
     */
private final static String secretKey = "whatever";
/**
     * 過期時間目前設置成2天,這個配置隨業務需求而定
     */
private final static Duration expiration = Duration.ofHours(2);
/**
     * 生成JWT
     * @param userName 用戶名
     * @return JWT
     */
public static String generate(String userName) {
// 過期時間
        Date expiryDate = new Date(System.currentTimeMillis() + expiration.toMillis());
return Jwts.builder()
                .setSubject(userName) // 將userName放進JWT
                .setIssuedAt(new Date()) // 設置JWT簽發時間
                .setExpiration(expiryDate) // 設置過期時間
                .signWith(SignatureAlgorithm.HS512, secretKey) // 設置加密算法和祕鑰
                .compact();
    }
/**
     * 解析JWT
     * @param token JWT字符串
     * @return 解析成功返回Claims對象,解析失敗返回null
     */
public static Claims parse(String token) {
// 如果是空字符串直接返回null
if (StringUtils.isEmpty(token)) {
return null;
        }
// 這個Claims對象包含了許多屬性,比如簽發時間、過期時間以及存放的數據等
        Claims claims = null;
// 解析失敗了會拋出異常,所以我們要捕捉一下。token過期、token非法都會導致解析失敗
try {
            claims = Jwts.parser()
                    .setSigningKey(secretKey) // 設置祕鑰
                    .parseClaimsJws(token)
                    .getBody();
        } catch (JwtException e) {
// 這裏應該用日誌輸出,爲了演示方便就直接打印了
            System.err.println("解析失敗!");
        }
return claims;
    }
}

工具類做好之後我們可以開始寫登錄接口了,和之前大同小異:

@RestController
public class JwtController {
@PostMapping("/login")
public String login(@RequestBody User user) {
// 判斷賬號密碼是否正確,這一步肯定是要讀取數據庫中的數據來進行校驗的,這裏爲了模擬就省去了
if ("admin".equals(user.getUsername()) && "admin".equals(user.getPassword())) {
// 如果正確的話就返回生成的token(注意哦,這裏服務端是沒有存儲任何東西的)
return JwtUtil.generate(user.getUsername());
        }
return "賬號密碼錯誤";
    }
}

在後續會話中,用戶訪問其他接口時就可以校驗 token 來判斷其是否已經登錄。前端將 token 一般會放在請求頭的 Authorization 項傳遞過來,其格式一般爲類型 + token。這個倒也不是一定得這麼做,你放在自己自定義的請求頭項也可以,只要和前端約定好就行。這裏我們方便演示就將 token 直接放在 Authorization 項裏了:

@GetMapping("api")
public String api(HttpServletRequest request) {
// 從請求頭中獲取token字符串
    String jwt = request.getHeader("Authorization");
// 解析失敗就提示用戶登錄
if (JwtUtil.parse(jwt) == null) {
return "請先登錄";
    }
// 解析成功就執行業務邏輯返回數據
return "api成功返回數據";
}
@GetMapping("api2")
public String api2(HttpServletRequest request) {
    String jwt = request.getHeader("Authorization");
if (JwtUtil.parse(jwt) == null) {
return "請先登錄";
    }
return "api2成功返回數據";
}

接下來我們測試一下效果,先進行登錄:

可以看到登錄成功後服務器返回了 token 過來,然後我們將這個 token 設置到請求頭中再調用其他接口看看效果:

可以看到成功返回數據了!我們再試一下不攜帶 token 和篡改 token 後調用其他接口會怎樣:

可以看到,沒有攜帶 token 或者私自篡改了 token 都會驗證失敗!

攔截器

和之前一樣,如果每個接口都要手動判斷一下用戶有沒有登錄太麻煩了,所以我們做一個統一處理,這裏我們換個花樣用攔截器來做:

public class LoginInterceptor extends HandlerInterceptorAdapter {
@Override
public boolean preHandle(HttpServletRequest request, HttpServletResponse response, Object handler) throws Exception {
// 簡單的白名單,登錄這個接口直接放行
if ("/login".equals(request.getRequestURI())) {
return true;
        }
// 從請求頭中獲取token字符串並解析
        Claims claims = JwtUtil.parse(request.getHeader("Authorization"));
// 已登錄就直接放行
if (claims != null) {
return true;
        }
// 走到這裏就代表是其他接口,且沒有登錄
// 設置響應數據類型爲json(前後端分離)
        response.setContentType("application/json;charset=utf-8");
        PrintWriter out = response.getWriter();
// 設置響應內容,結束請求
out.write("請先登錄");
out.flush();
out.close();
return false;
    }
}

攔截器類寫好之後,別忘了要使其生效,這裏我們直接讓 SpringBoot 啓動類實現 WevMvcConfigurer 接口來做:

@SpringBootApplication
public class LoginJwtApplication implements WebMvcConfigurer {
public static void main(String[] args) {
        SpringApplication.run(LoginJwtApplication.class, args);
    }
@Override
public void addInterceptors(InterceptorRegistry registry) {
// 使攔截器生效
        registry.addInterceptor(new LoginInterceptor());
    }
}

這樣就能省去接口中校驗用戶登錄的邏輯了:

@GetMapping("api")
public String api() {
return "api成功返回數據";
}
@GetMapping("api2")
public String api2() {
return "api2成功返回數據";
}

可以看到,攔截器已經生效了!

上下文對象

統一攔截做好之後接下來就是我們的上下文對象,JWT 不像 Session 把用戶信息直接存儲起來,所以 JWT 的上下文對象要靠我們自己來實現。

首先我們定義一個上下文類,這個類專門存儲 JWT 解析出來的用戶信息。我們要用到 ThreadLocal,以防止線程衝突:

public final class UserContext {
private static final ThreadLocal<String> user = new ThreadLocal<String>();
public static void add(String userName) {
        user.set(userName);
    }
public static void remove() {
        user.remove();
    }
/**
     * @return 當前登錄用戶的用戶名
     */
public static String getCurrentUserName() {
return user.get();
    }
}

這個類創建好之後我們還需要在攔截器裏做下處理:

public class LoginInterceptor extends HandlerInterceptorAdapter {
@Override
public boolean preHandle(HttpServletRequest request, HttpServletResponse response, Object handler) throws Exception {
        ...省略之前寫的代碼
// 從請求頭中獲取token字符串並解析
        Claims claims = JwtUtil.parse(request.getHeader("Authorization"));
// 已登錄就直接放行
if (claims != null) {
// 將我們之前放到token中的userName給存到上下文對象中
            UserContext.add(claims.getSubject());
return true;
        }
        ...省略之前寫的代碼
    }
@Override
public void afterCompletion(HttpServletRequest request, HttpServletResponse response, Object handler, Exception ex) throws Exception {
// 請求結束後要從上下文對象刪除數據,如果不刪除則可能會導致內存泄露
        UserContext.remove();
super.afterCompletion(request, response, handler, ex);
    }
}

這樣一個上下文對象就做好了,用法和之前一樣,可以在程序的其他地方直接獲取到數據,我們在 Service 層中來使用它:

public void doSomething() {
    String currentUserName = UserContext.getCurrentUserName();
    System.out.println("Service層---當前用戶登錄名:" + currentUserName);
}

然後 Controller 層調用 Service 層:

@GetMapping("api")
public String api() {
    userService.doSomething();
return "api成功返回數據";
}

這樣一套做好之後,看下前端成功調用 API 接口時的效果:

控制檯成功打印了!

補充

代碼到此就完成了!就像開頭所說,本文只是講解了基本的登錄認證功能實現,還有很多很多細節沒有提及,比如密碼加密、防 XSS/CSRF 攻擊等。接下來我要補充的也不是這些細節,而是補充一些其他的基礎知識,幫助大家更好的理解本文講解的內容!

注意事項

本文爲了方便演示省略了很多非登錄認證核心的相關代碼,比如在統一處理中如果發現用戶沒有登錄應該是直接拋出自定義異常,然後由異常全局處理返回給前端統一的數據響應體,而不是像我們現在代碼中一樣直接用 PrintWriter 輸出流輸出數據。

再有就是 JWT 的相關注意點。通過代碼看到生成一個 JWT 字符串很簡單,誰都可以生成。然後字符串這東西也誰都可以篡改,我們怎麼保證這個字符串就是我們系統簽發出去的呢?又怎麼保證我們簽發出去的字符串有沒有被篡改呢? 其中關鍵點就是工具類裏寫的 secretKey 屬性了。

JWT 根據這個祕鑰會生成一個獨特的字符串,別人沒有這個祕鑰的話是無法僞造或篡改 JWT 的!所以這個祕鑰是重中之重,在實際開發中一定要謹防泄露:開發環境下設置一個祕鑰,生產環境設置一個祕鑰,這個生產環境下的祕鑰還要嚴防死守,可以通過配置中心來配置並且要防止開發人員在代碼中打印出祕鑰!

我們代碼中演示的 JWT 是隻存放了用戶名,實際開發中你想存什麼就存什麼,不過一定不要存敏感信息(比如密碼)!因爲 JWT 只能防止被篡改,不能防止別人解密你這個字符串!

Session 和 JWT 的優劣

兩種方式都可以實現登錄認證,那麼在實際開發中到底用哪一種估計是大家比較關心的問題!在這裏我就簡單說明一下兩者各自的優劣,至於具體選型就根據自己實際業務需求來了。首先說一下兩者的優點

Session 的優點:

JWT 的優點:

再說一下缺點

Session 的缺點:較 JWT 而言,需要額外存儲數據。

JWT 的缺點:

其實上面說的這些優缺點都可以通過一些手段來解決,就看自己取捨了!比如 Session 就不易於水平擴展嗎?當然不是,無論是 Session 同步機制還是集中管理都可以非常好的解決。再比如 JWT 就真的無法銷燬嗎?當然也不是,其實可以將 Token 也在後端存儲起來讓其變成有狀態的,就可以做到狀態管理了!

軟件開發沒有銀彈,技術選型根據自己業務需求來就好,千萬不要單一崇拜某一技術而排斥其他同類技術!

收尾

OK,文章到這就要結束了!本文重點不是具體的代碼,而是登錄認證的基本原理!原理搞懂了,不管什麼方式都是大同小異。

本文所有代碼都放在了 Github 上,clone 下來即可運行查看效果!如果對你有幫助麻煩點個 Star 哦,我會持續更多【項目實踐】的!

GitHub:https://codechina.csdn.net/mirrors/RudeCrab/rude-java

本文由 Readfog 進行 AMP 轉碼,版權歸原作者所有。
來源https://mp.weixin.qq.com/s/tXAzprWqYSs-snpU_QOyzg