一天,正是午休時段
兵長路過胖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的組成部分。
啥時候使用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 協議》,轉載必須註明作者和本文連結