【項目實踐】一文帶你搞定Session和JWT的登錄認證方式


以項目驅動學習,以實踐檢驗真知

前言

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

本文是一個系列文章,最終的目的是想與大家分享在實際項目中如何運用安全框架完成登錄認證(Authentication)、權限授權(Authorization)等功能。只不過一上來就講框架的配置和使用我覺得並不好,一是對於新手來說會很懵逼,二是不利於大家對框架的深入理解。所以本文先寫 手擼登錄認證基本功能,下一篇文章再寫 不用安全框架手擼權限授權,最后再寫 如何運用安全框架整合這些功能。

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

  • 登錄認證的原理

  • 如何使用SessionJWT分別完成登錄認證功能

  • 如何使用過濾器和攔截器分別完成對登錄認證的統一處理

  • 如何實現上下文對象

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

基礎知識

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

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

A:你早上吃的啥?

B:小籠包。

A:味道咋樣啊?

B:哈?啥味道咋樣?!

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

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

A:你早上吃的啥?

B:小籠包。

A:你早上吃的小籠包味道咋樣啊?

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 "成功返回數據";
}

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

請先登錄.png

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

登錄成功.png

登錄成功后我們再調用剛才的接口:

成功返回數據.png

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

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

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

Set-Cookie.png

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

攜帶Cookie.png

每一個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成功返回數據";
}

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

過濾器.png

過濾器生效了!

上下文對象

在有些情況下,就算加了過濾器后我們現在還不能在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接口時的效果:

當前登錄用戶對象.png

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

JWT

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

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

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

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

JWT流程.png

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

沒錯,整體流程來說是一樣的,我之前也說了,無論哪種方式其核心都是TOKEN機制。但,SessionJWT有一個重要的區別,就是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.png

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

攜帶token.png

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

image-20200829145425305.png

篡改token.png

可以看到,沒有攜帶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成功返回數據";
}

攔截器.png

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

上下文對象

統一攔截做好之后接下來就是我們的上下文對象,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接口時的效果:

當前登錄用戶名.png

控制台成功打印了!

補充

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

注意事項

本文為了方便演示省略了很多非登錄認證核心的相關代碼,比如在統一處理中如果發現用戶沒有登錄應該是直接拋出自定義異常,然后由異常全局處理返回給前端統一的數據響應體,而不是像我們現在代碼中一樣直接用PrintWriter輸出流輸出數據。關於這方面可以參考我之前寫的博客進行改造:【項目實踐】SpringBoot三招組合拳,手把手教你打出優雅的后端接口

再有就是JWT的相關注意點。通過代碼看到生成一個JWT字符串很簡單,誰都可以生成。然后字符串這東西也誰都可以篡改,我們怎么保證這個字符串就是我們系統簽發出去的呢?又怎么保證我們簽發出去的字符串有沒有被篡改呢? 其中關鍵點就是工具類里寫的secretKey屬性了,JWT根據這個秘鑰會生成一個獨特的字符串,別人沒有這個秘鑰的話是無法偽造或篡改JWT的!所以這個秘鑰是重中之重,在實際開發中一定要謹防泄露:開發環境下設置一個秘鑰,生產環境設置一個秘鑰,這個生產環境下的秘鑰還要嚴防死守,可以通過配置中心來配置並且要防止開發人員在代碼中打印出秘鑰!

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

Session和JWT的優劣

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

首先說一下兩者的優點吧:

Session:

  • 開箱即用,簡單方便
  • 能夠有效管理用戶登錄的狀態:續期、銷毀等(續期就是延長用戶登錄狀態的時間,銷毀就是清楚用戶登錄狀態,比如退出登錄)

JWT:

  • 可直接解析出數據,服務端無需存儲數據
  • 天然地易於水平擴展(ABC三個系統,我同一個Token都可以登錄認證,非常簡單就完成了單點登錄)

再說一下缺點:

Session:

  • JWT而言,需要額外存儲數據

JWT:

  • JWT簽名的長度遠比一個 Session Id長很多,增加額外網絡開銷

  • 無法銷毀、續期登錄狀態

  • 秘鑰或Token一旦泄露,攻擊者便可以肆無忌憚操作我們的系統

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

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

收尾

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

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

下一篇文章就開始寫如何實現權限授權功能,也是從最最簡單的知識講起,輕松無壓力看到最后就會發現自己已經會了!


免責聲明!

本站轉載的文章為個人學習借鑒使用,本站對版權不負任何法律責任。如果侵犯了您的隱私權益,請聯系本站郵箱yoyou2525@163.com刪除。



 
粵ICP備18138465號   © 2018-2025 CODEPRJ.COM