JWT身份認證(附帶原始碼講解)

小魔童哪吒發表於2021-03-31
[TOC]

一天,正是午休時段

兵長路過胖sir座位,大吃一驚,今天胖sir居然沒有打呼嚕,而是在低著頭聚精會神盯著一本書

兵長湊近一看,胖sir居然在看史書…

兵長:(輕聲道),你在看~ 什 ~ 麼 ~~

胖sir:我在想我要是穿越到清朝,我會是啥身份?

what??~ , 能是啥身份,肯定是重量級人物唄

胖sir: 我呸, 今天我倒要給你講講啥叫身份

講到身份,不得不說一下cookie、session、Token的區別,come on

1 cookie、session、Token的區別

Cookie

Cookie總是儲存在客戶端中,按在客戶端中的儲存位置,可分為 記憶體Cookie硬碟Cookie

記憶體Cookie由瀏覽器維護,儲存在記憶體中,瀏覽器關閉後就消失了,其存在時間是短暫的。

硬碟Cookie儲存在硬碟⾥,有⼀個過期時間,除⾮⽤戶⼿⼯清理或到了過期時間,硬碟Cookie不會被刪除,其存在時間 是⻓期的。

所以,按存在時間,可分為 ⾮持久Cookie持久Cookie

那麼cookies到底是什麼呢?

cookie 是⼀個⾮常具體的東⻄,指的就是瀏覽器⾥⾯能永久儲存的⼀種資料,僅僅是瀏覽器實現的⼀種數

據儲存功能。

cookie由伺服器⽣成,傳送給瀏覽器 ,瀏覽器把cookie以key-value形式儲存到某個⽬錄下的⽂本⽂件

內,下⼀次請求同⼀⽹站時會把該cookie傳送給伺服器。由於cookie是存在客戶端上的,所以瀏覽器加⼊

了⼀些限制確保cookie不會被惡意使⽤,同時不會佔據太多磁碟空間,所以每個域的cookie數量是有限的。

Session

Session字⾯意思是會話,主要⽤來標識⾃⼰的身份。

⽐如在⽆狀態的api服務在多次請求資料庫時,如何 知道是同⼀個⽤戶,這個就可以通過session的機制,伺服器要知道當前發請求給⾃⼰的是誰,為了區分客戶端請求, 服務端會給具體的客戶端⽣成身份標識session ,然後客戶端每次向伺服器發請求 的時候,都帶上這個“身份標識”,伺服器就知道這個請求來⾃於誰了。

⾄於客戶端如何儲存該標識,可以有很多⽅式,對於瀏覽器⽽⾔,⼀般都是使⽤ cookie 的⽅式 ,伺服器使⽤session把⽤戶資訊臨時儲存了伺服器上,⽤戶離開⽹站就會銷燬,這種憑證儲存⽅式相對於 ,cookie來說更加安全。

但是session會有⼀個缺陷: 如果web伺服器做了負載均衡,那麼下⼀個操作請求到 了另⼀臺伺服器的時候session會丟失。

因此,通常企業⾥會使⽤ redis,memcached 快取中介軟體來實現session的共享,此時web伺服器就是⼀ 個完全⽆狀態的存在,所有的⽤戶憑證可以通過共享session的⽅式存取,當前session的過期和銷燬機制 需要⽤戶做控制。

Token

token的意思是“令牌”,是⽤戶身份的驗證⽅式,最簡單的token組成: uid(⽤戶唯⼀標識) + time(當前 時間戳) + sign(簽名,由token的前⼏位+鹽以雜湊演算法壓縮成⼀定⻓度的⼗六進位制字串) ,同時還可 以將不變的引數也放進token

這裡說的token只的是 JWT(Json Web Token)

2 JWT是個啥?

⼀般⽽⾔,⽤戶註冊登陸後會⽣成⼀個jwt token返回給瀏覽器,瀏覽器向服務端請求資料時攜帶 token ,伺服器端使⽤ signature 中定義的⽅式進⾏解碼,進⽽對token進⾏解析和驗證。

jwt token 的組成部分

  • header: ⽤來指定使⽤的演算法(HMAC SHA256 RSA)和token型別(如JWT)

    官網上可以找到各種語言的jwt庫,例如我們下面使用這個庫進行編碼,因為這個庫使用的人是最多的,值得信賴

    go get github.com/dgrijalva/jwt-go

  • payload: 包含宣告(要求),宣告通常是⽤戶資訊或其他資料的宣告,⽐如⽤戶id,名稱,郵箱等. 宣告。可分為三種: registered,public,private

  • signature: ⽤來保證JWT的真實性,可以使⽤不同的演算法

header

token的第一部分,如

{
  "alg": "HS256",
  "typ": "JWT"
}

對上⾯的json進⾏base64編碼即可得到JWT的第⼀個部分

payload

token第二部分如

  • registered claims: 預定義的宣告,通常會放置⼀些預定義欄位,⽐如過期時間,主題等(iss:issuer,exp:expiration time,sub:subject,aud:audience)

  • public claims: 可以設定公開定義的欄位

  • private claims: ⽤於統⼀使⽤他們的各⽅之間的共享資訊

不要在header和payload中放置敏感資訊,除⾮資訊本身已經做過脫敏處理,因為payload部分的具體資料是可以通過token來獲取到的

{
  "sub": "1234567890",
  "name": "John Doe",
  "admin": true
}

Signature

token的第三部分

為了得到簽名部分,必須有編碼過的header和payload,以及⼀個祕鑰,簽名演算法使⽤header中指定的那 個,然後對其進⾏簽名即可

HMACSHA256(base64UrlEncode(header)+”.”+base64UrlEncode(payload),secret)

簽名是 ⽤於驗證訊息在傳遞過程中有沒有被更改 ,並且,對於使⽤私鑰簽名的token,它還可以驗證JWT

的傳送⽅是否為它所稱的傳送⽅。

簽名的⽬的

最後⼀步簽名的過程,實際上是對頭部以及載荷內容進⾏簽名。

⼀般⽽⾔,加密演算法對於不同的輸⼊ 產⽣的輸出總是不⼀樣的。對於兩個不同的輸⼊,產⽣同樣的輸出的概率極其地⼩。所以,我們就把“不⼀樣的輸⼊產⽣不⼀樣的輸出”當做必然事件來看待。

所以,如果有⼈對頭部以及載荷的內容解碼之後進⾏修改,再進⾏編碼的話,那麼新的頭部和載荷的 簽名和之前的簽名就將是不⼀樣的。⽽且,如果不知道伺服器加密的時候⽤的金鑰的話,得出來的簽名也 ⼀定會是不⼀樣的。

伺服器應⽤在接受到JWT後,會⾸先對頭部和載荷的內容⽤同⼀演算法再次簽名。那麼伺服器應⽤是怎 麼知道我們⽤的是哪⼀種演算法呢?

在JWT的頭部中已經⽤alg欄位指明瞭我們的加密演算法了

如果伺服器應⽤對頭部和載荷再次以同樣⽅法簽名之後發現,⾃⼰計算出來的簽名和接受到的簽名不 ⼀樣,那麼就說明這個Token的內容被別⼈動過的,我們應該拒絕這個Token,

注意:在JWT中,不應該在載荷⾥⾯加⼊任何敏感的資料,⽐如⽤戶的密碼。具體原因上文已經給過答案了

jwt.io⽹站

在jwt.io(https://jwt.io/#debugger-io)⽹站中,提供了⼀些JWT token的編碼,驗證以及⽣成jwt的⼯具。

下圖就是⼀個典型的jwt-token的組成部分。

image-20210328145831017

啥時候使用JWT呢?

我們要明白的時候,JWT是用作認證的,而不是用來做授權的。明白他的功能,那麼對應JWT的應用場景就不言而喻了

  • Authorization(授權): 典型場景,⽤戶請求的token中包含了該令牌允許的路由,服務和資源。單點登入其實就是現在⼴泛使⽤JWT的⼀個特性

  • Information Exchange(資訊交換): 對於安全的在各⽅之間傳輸資訊⽽⾔,JSON Web Tokens⽆疑 是⼀種很好的⽅式.因為JWTs可以被簽名

    例如,⽤公鑰/私鑰對,你可以確定傳送⼈就是它們所說的 那個⼈。另外,由於簽名是使⽤頭和有效負載計算的,您還可以驗證內容沒有被篡改

JWT工作方式是怎樣的?

JWT認證過程基本上整個過程分為兩個階段

  • 第⼀個階段,客戶端向服務端獲取token
  • 第⼆階段,客戶端帶著該token去請求相關的資源

通常⽐較重要的是,服務端如何根據指定的規則進⾏token的⽣成。

在認證的時候,當⽤戶⽤他們的憑證成功登入以後,⼀個JSON Web Token將會被返回。 此後,token就是⽤戶憑證了,你必須⾮常⼩⼼以防⽌出現安全問題。 ⼀般⽽⾔,你儲存令牌的時間不應該超過你所需要它的時間。

⽆論何時⽤戶想要訪問受保護的路由或者資源的時候,⽤戶代理(通常是瀏覽器)都應該帶上JWT,典型 的,通常放在Authorization header中,⽤Bearer schema: Authorization: Bearer 伺服器上的受保護的路由將會檢查Authorization header中的JWT是否有效,如果有效,則⽤戶可以訪問 受保護的資源。

如果JWT包含⾜夠多的必需的資料,那麼就可以減少對某些操作的資料庫查詢的需要,儘管可能並不總是如此。 如果token是在授權頭(Authorization header)中傳送的,那麼跨源資源共享(CORS)將不會成為問題,因為它不使⽤cookie。

來感受一張官方的圖

獲取JWT以及訪問APIs以及資源

  • 客戶端向授權接⼝請求授權

  • 服務端授權後返回⼀個access token給客戶端

  • 客戶端使⽤access token訪問受保護的資源

3 基於Token的身份認證和基於伺服器的身份認證

1、給予伺服器的身份認證,通常是基於伺服器上的session來做使用者認證,使用session會有如下幾個問題

  • Sessions:認證通過後需要將⽤戶的session資料儲存在記憶體中,隨著認證⽤戶的增加,記憶體開銷會⼤
  • 擴充套件性問題: 由於session儲存在記憶體中,擴充套件性會受限,雖然後期可以使⽤redis,memcached來快取資料
  • CORS: 當多個終端訪問同⼀份資料時,可能會遇到禁⽌請求的問題
  • CSRF: ⽤戶容易受到CSRF攻擊(Cross Site Request Forgery, 跨站域請求偽造)

2、基於Token的身份認證證是⽆狀態的,伺服器或者session中不會儲存任何⽤戶資訊.(很好的解決了共享 session的問題)

  • ⽤戶攜帶⽤戶名和密碼請求獲取token(接⼝資料中可使⽤appId,appKey,或是自己協商好的某類資料)

  • 服務端校驗⽤戶憑證,並返回⽤戶或客戶端⼀個Token

  • 客戶端儲存token,並在請求頭中攜帶Token

  • 服務端校驗token並返回相應資料

需要注意幾點:

  • 客戶端請求伺服器的時候,必須將token放到header中
  • 客戶端請求伺服器每一次都需要帶上token
  • 伺服器需要設定 為接收所有域的請求: Access-Control-Allow-Origin: *

3、Session和JWT Token的有啥不一樣的?

  • 他倆都可以儲存使用者相關的資訊
  • session 儲存在伺服器, JWT儲存在客戶端

4 ⽤Token有什麼好處呢?

  • 他是無狀態的 且 可擴充套件性好
  • 他相對安全:防⽌CSRF攻擊,token過期重新認證

上文有說說,JWT是用於做身份認證的而不是做授權的,那麼在這裡列舉一下 做認證和做授權分別用在哪裡呢?

  • 例如OAuth2是⼀種授權框架,是用於授權,主要用在 使⽤第三⽅賬號登入的情況 (⽐如使⽤weibo, qq, github登入某個app)
  • JWT是⼀種認證協議 ,⽤在 前後端分離 , 需要簡單的對後臺API進⾏保護時使⽤
  • 無論是授權還是認證,都需要記住使用HTTPS來保護資料的安全性

5 實際看看JWT如何做身份驗證

  • jwt做身份驗證,這裡主要講如何根據header,payload,signature生成token
  • 客戶端帶著token來伺服器做請求,如何校驗?

下面例項程式碼,主要做了2個介面

用到的技術點:

  • gin
    • 路由分組
    • 中介軟體的使用
  • gorm
    • 簡單操作mysql資料庫,插入,查詢
  • jwt
    • 生成token
    • 解析token

登入介面

訪問url : 127.0.0.1:9999/v1/login

功能:

  • 使用者登入
  • 生成jwt,並返回給到客戶端
  • gorm對資料庫的操作

認證後Hello介面

訪問url : 127.0.0.1:9999/v1/auth/hello

功能:

  • 校驗 客戶端請求伺服器攜帶token
  • 返回客戶端所請求的資料

程式碼結構如下圖

main.go

package main

import (
   "github.com/gin-gonic/gin"
   "my/controller"
   "my/myauth"
)

func main() {
   //連線資料庫
   conErr := controller.InitMySQLCon()
   if conErr != nil {
      panic(conErr)
   }

   //需要使用到gorm,因此需要先做一個初始化
   controller.InitModel()
   defer controller.DB.Close()


   route := gin.Default()

   //路由分組
   v1 := route.Group("/v1/")
   {
      //登入(為了方便,將註冊和登入功能寫在了一起)
      v1.POST("/login", controller.Login)
   }


   v2 := route.Group("/v1/auth/")
   //一個身份驗證的中介軟體
   v2.Use(myauth.JWTAuth())
   {
      //帶著token請求伺服器
      v2.POST("/hello", controller.Hello)
   }

   //監聽9999埠
   route.Run(":9999")

}

controller.go

檔案中基本的資料結構定義為:

檔案中涉及的處理函式:

實際原始碼:

package controller

import (
   "errors"
   "fmt"
   jwtgo "github.com/dgrijalva/jwt-go"
   "github.com/gin-gonic/gin"
   "github.com/jinzhu/gorm"
   "log"
   "net/http"
   "time"
   _ "github.com/jinzhu/gorm/dialects/mysql"
)

//登入請求資訊
type ReqInfo struct {
   Name   string `json:"name"`
   Passwd string `json:"passwd"`
}

// 構造使用者表
type MyInfo struct {
   Id        int32  `gorm:"AUTO_INCREMENT"`
   Name      string `json:"name"`
   Passwd    string `json:"passwd"`
   CreatedAt *time.Time
   UpdateTAt *time.Time
}

//Myclaims
// 定義載荷
type Myclaims struct {
   Name string `json:"userName"`
   // StandardClaims結構體實現了Claims介面(Valid()函式)
   jwtgo.StandardClaims
}
//金鑰
type JWT struct {
   SigningKey []byte
}
//hello 介面
func Hello(c *gin.Context) {
   claims, _ := c.MustGet("claims").(*Myclaims)
   if claims != nil {
      c.JSON(http.StatusOK, gin.H{
         "status": 0,
         "msg":    "Hello wrold",
         "data":   claims,
      })
   }
}

var (
   DB               *gorm.DB
   secret                 = "iamsecret"
   TokenExpired     error = errors.New("Token is expired")
   TokenNotValidYet error = errors.New("Token not active yet")
   TokenMalformed   error = errors.New("That's not even a token")
   TokenInvalid     error = errors.New("Couldn't handle this token:")
)
//資料庫連線
func InitMySQLCon() (err error) {
   // 可以在api包裡設定成init函式
   connStr := fmt.Sprintf("%s:%s@tcp(%s:%d)/%s?charset=utf8mb4&parseTime=True&loc=Local", "root", "123456", "127.0.0.1", 3306, "mygorm")
   fmt.Println(connStr)
   DB, err = gorm.Open("mysql", connStr)

   if err != nil {
      return err
   }

   return DB.DB().Ping()
}
//初始化gorm物件對映
func InitModel() {
   DB.AutoMigrate(&MyInfo{})
}
func NewJWT() *JWT {
   return &JWT{
      []byte(secret),
   }
}
// 登陸結果
type LoginResult struct {
   Token string `json:"token"`
   Name string `json:"name"`
}
// 建立Token(基於使用者的基本資訊claims)
// 使用HS256演算法進行token生成
// 使用使用者基本資訊claims以及簽名key(signkey)生成token
func (j *JWT) CreateToken(claims Myclaims) (string, error) {
   // 返回一個token的結構體指標
   token := jwtgo.NewWithClaims(jwtgo.SigningMethodHS256, claims)
   return token.SignedString(j.SigningKey)
}
//生成token
func generateToken(c *gin.Context, info ReqInfo) {
   // 構造SignKey: 簽名和解簽名需要使用一個值
   j := NewJWT()

   // 構造使用者claims資訊(負荷)
   claims := Myclaims{
      info.Name,
      jwtgo.StandardClaims{
         NotBefore: int64(time.Now().Unix() - 1000), // 簽名生效時間
         ExpiresAt: int64(time.Now().Unix() + 3600), // 簽名過期時間
         Issuer:    "pangsir",                       // 簽名頒發者
      },
   }

   // 根據claims生成token物件
   token, err := j.CreateToken(claims)
   if err != nil {
      c.JSON(http.StatusOK, gin.H{
         "status": -1,
         "msg":    err.Error(),
         "data":   nil,
      })
   }

   log.Println(token)
   // 返回使用者相關資料
   data := LoginResult{
      Name:  info.Name,
      Token: token,
   }

   c.JSON(http.StatusOK, gin.H{
      "status": 0,
      "msg":    "登陸成功",
      "data":   data,
   })

   return
}
//解析token
func (j *JWT) ParserToken(tokenstr string) (*Myclaims, error) {
   // 輸入token
   // 輸出自定義函式來解析token字串為jwt的Token結構體指標
   // Keyfunc是匿名函式型別: type Keyfunc func(*Token) (interface{}, error)
   // func ParseWithClaims(tokenString string, claims Claims, keyFunc Keyfunc) (*Token, error) {}
   token, err := jwtgo.ParseWithClaims(tokenstr, &Myclaims{}, func(token *jwtgo.Token) (interface{}, error) {
      return j.SigningKey, nil
   })

   fmt.Println(token, err)
   if err != nil {
      // jwt.ValidationError 是一個無效token的錯誤結構
      if ve, ok := err.(*jwtgo.ValidationError); ok {
         // ValidationErrorMalformed是一個uint常量,表示token不可用
         if ve.Errors&jwtgo.ValidationErrorMalformed != 0 {
            return nil, TokenMalformed
            // ValidationErrorExpired表示Token過期
         } else if ve.Errors&jwtgo.ValidationErrorExpired != 0 {
            return nil, TokenExpired
            // ValidationErrorNotValidYet表示無效token
         } else if ve.Errors&jwtgo.ValidationErrorNotValidYet != 0 {
            return nil, TokenNotValidYet
         } else {
            return nil, TokenInvalid
         }

      }
   }

   // 將token中的claims資訊解析出來和使用者原始資料進行校驗
   // 做以下型別斷言,將token.Claims轉換成具體使用者自定義的Claims結構體
   if claims, ok := token.Claims.(*Myclaims); ok && token.Valid {
      return claims, nil
   }

   return nil, errors.New("token NotValid")
}
//登入
func Login(c *gin.Context) {
   var reqinfo ReqInfo
   var userInfo MyInfo

   err := c.BindJSON(&reqinfo)

   if err == nil {
      fmt.Println(reqinfo)

      if reqinfo.Name == "" || reqinfo.Passwd == ""{
         c.JSON(http.StatusOK, gin.H{
            "status": -1,
            "msg":    "賬號密碼不能為空",
            "data":   nil,
         })
         c.Abort()
         return
      }
      //校驗資料庫中是否有該使用者
      err := DB.Where("name = ?", reqinfo.Name).Find(&userInfo)
      if err != nil {
         fmt.Println("資料庫中沒有該使用者 ,可以進行新增使用者資料")
         //新增使用者到資料庫中
         info := MyInfo{
            Name:   reqinfo.Name,
            Passwd: reqinfo.Passwd,
         }
         dberr := DB.Model(&MyInfo{}).Create(&info).Error
         if dberr != nil {
            c.JSON(http.StatusOK, gin.H{
               "status": -1,
               "msg":    "登入失敗,資料庫操作錯誤",
               "data":   nil,
            })
            c.Abort()
            return
         }
      }else{
         if userInfo.Name != reqinfo.Name || userInfo.Passwd != reqinfo.Passwd{
            c.JSON(http.StatusOK, gin.H{
               "status": -1,
               "msg":    "賬號密碼錯誤",
               "data":   nil,
            })
            c.Abort()
            return
         }
      }
      //建立token
      generateToken(c, reqinfo)
   } else {
      c.JSON(http.StatusOK, gin.H{
         "status": -1,
         "msg":    "登入失敗,資料請求錯誤",
         "data":   nil,
      })
   }
}

myauth.go

package myauth

import (
   "fmt"
   "github.com/gin-gonic/gin"
   "my/controller"
   "net/http"
)

//身份認證
func JWTAuth() gin.HandlerFunc {
   return func(c *gin.Context) {
      //拿到token
      token := c.Request.Header.Get("token")
      if token == "" {
         c.JSON(http.StatusOK, gin.H{
            "status": -1,
            "msg":    "token為空,請攜帶token",
            "data":   nil,
         })
         c.Abort()
         return
      }

      fmt.Println("token = ", token)

      //解析出實際的載荷
      j := controller.NewJWT()

      claims, err := j.ParserToken(token)
      if err != nil {
         // token過期
         if err == controller.TokenExpired {
            c.JSON(http.StatusOK, gin.H{
               "status": -1,
               "msg":    "token授權已過期,請重新申請授權",
               "data":   nil,
            })
            c.Abort()
            return
         }
         // 其他錯誤
         c.JSON(http.StatusOK, gin.H{
            "status": -1,
            "msg":    err.Error(),
            "data":   nil,
         })
         c.Abort()
         return
      }

      // 解析到具體的claims相關資訊
      c.Set("claims", claims)
   }
}

myauth.go

package myauth

import (
   "fmt"
   "github.com/gin-gonic/gin"
   "my/controller"
   "net/http"
)

//身份認證
func JWTAuth() gin.HandlerFunc {
   return func(c *gin.Context) {
      //拿到token
      token := c.Request.Header.Get("token")
      if token == "" {
         c.JSON(http.StatusOK, gin.H{
            "status": -1,
            "msg":    "token為空,請攜帶token",
            "data":   nil,
         })
         c.Abort()
         return
      }

      fmt.Println("token = ", token)

      //解析出實際的載荷
      j := controller.NewJWT()

      claims, err := j.ParserToken(token)
      if err != nil {
         // token過期
         if err == controller.TokenExpired {
            c.JSON(http.StatusOK, gin.H{
               "status": -1,
               "msg":    "token授權已過期,請重新申請授權",
               "data":   nil,
            })
            c.Abort()
            return
         }
         // 其他錯誤
         c.JSON(http.StatusOK, gin.H{
            "status": -1,
            "msg":    err.Error(),
            "data":   nil,
         })
         c.Abort()
         return
      }

      // 解析到具體的claims相關資訊
      c.Set("claims", claims)
   }
}

6 jwt是如何將header,paylaod,signature組裝在一起的?

1> 我們從建立token的函式開始看起

CreateToken用JWT物件繫結,物件中包含金鑰,函式的引數是載荷

2> NewWithClaims 函式引數是加密演算法,載荷

NewWithClaims的具體作用是是初始化一個Token物件

3> SignedString函式,引數為金鑰

主要是得到一個完整的token

SigningString 將header 與 載荷 處理後拼接在一起

Sign 將金鑰計算一個hash值,與header,載荷拼接在一起,進而製作成token

此處的Sign 方法具體是呼叫哪一個實現,請繼續往下看

4> SigningString

將header通過json序列化之後使用base64加密

同樣的也將載荷通過json序列化之後使用base64加密

將這倆加密後的字串拼接在一起

5> 回到建立token函式的位置

func (j *JWT) CreateToken(claims Myclaims) (string, error) {
   // 返回一個token的結構體指標
   token := jwtgo.NewWithClaims(jwtgo.SigningMethodHS256, claims)
   return token.SignedString(j.SigningKey)
}

SigningMethodHS256 對應這一個結構SigningMethodHMAC,如下

看到這裡,便解開了上述第4點 Sign方法具體在哪裡實現的問題

7> 效果檢視

登入&註冊介面

資料庫展示(若對編碼中的gorm有疑問,可以看小魔童哪吒的上一期gorm的整理)

Hello介面

以上為本期全部內容,如有疑問可以在評論區或後臺提出你的疑問,我們一起交流,一起成長。

好傢伙要是文章對你還有點作用的話,請幫忙點個關注,分享到你的朋友圈,分享技術,分享快樂

技術是開放的,我們的心態,更應是開放的。擁抱變化,向陽而生,努力向前行。

作者:小魔童哪吒

本作品採用《CC 協議》,轉載必須註明作者和本文連結

相關文章