基于OAuth2.0和JWT规范实现安全易用的用户认证

文章目录

    • 预备知识
    • 基本思路
    • 详细步骤
      • [1. 客户端提交认证请求](#1. 客户端提交认证请求)
      • [2. 服务端验证用户登录](#2. 服务端验证用户登录)
      • [3. 服务端颁发JWT Token](#3. 服务端颁发JWT Token)
      • [4. 客户端管理 JWT token](#4. 客户端管理 JWT token)
      • [5. 客户端后续请求](#5. 客户端后续请求)
      • [6. 服务端验证客户端的token](#6. 服务端验证客户端的token)
    • 总结
    • 查看完整代码

遵循OAuth2.0JWT规范实现用户认证,不但具有很好的实用性,还能提供很不错的安全保障。

本文结合实用的代码讲述了基于OAuth2.0JWT,在前后端分离的系统中,实现用户使用方便而又安全可靠的用户认证的基本思路。

预备知识

OAuth2.0

OAuth2.0 是一个关于授权(authorization)的开放网络标准,在全世界得到广泛应用。比如:微信登录、Facebook,Google,Twitter,GitHub等。
OAuth2.0规范要求使用密码流时,客户端或用户必须以表单数据形式发送 usernamepassword 字段。这两个字段必须命名为 usernamepassword ,不能使用 user-nameemail 等其它名称。

该规范要求必须以表单数据形式发送 username 和 password,因此,不能使用 JSON 对象。

当然,前端仍可以显示终端用户所需的名称,数据库模型也可以使用自己定义的名称。

关于OAuth的更多知识,您可以参考:理解OAuth2.0

JWT

JWT 即JSON 网络令牌(JSON Web Tokens),是目前最流行的跨域认证解决方案。

它在服务端将用户信息进行签名(如果有保密信息也可以加密后再签名),这样可以防止它被篡改。

每次客户端提交请求时,都附带 JWT 内容,这样服务端可以直接读取用户信息,而不用必须从服务器端的会话中获取。

关于JWT的更多知识,可以参考:JSON Web Token 入门教程

基本思路

以下是基本的实现思路:

  1. 客户端以表单方式,携带usernamepassword向后台接口发起登录认证请求
  2. 服务端接口验证账号和密码后,生成JWT格式的token,其中放置加密信息,签名后发放给客户端
  3. 客户端再访问服务端接口时,在HTTP Header中携带token即可

详细步骤

这些步骤中包含的代码片段的目的是能把过程讲得更清楚,如果想查看详细的代码并且自己动手实践,可参见本文最后的代码下载地址。

以下代码片段前端使用javascript和vue3,后端使用的是python

1. 客户端提交认证请求

前端javascript发送form请求:

vue3 复制代码
import { ref } from "vue";
import axios from "axios";
import { jwtDecode } from "jwt-decode";

import { setToken } from "@/assets/js/auth";

const login_url = "http://127.0.0.1:8000/token";

//表单数据
const form_data = ref({
  username: "",
  password: "",
  remember: false,
});

const isLoading = ref(false);
const error = ref(false);
const error_msg = ref("");

// 获取路由实例
import { useRoute ,useRouter } from "vue-router";
const route = useRoute();
const router = useRouter();

const emits = defineEmits(["login"]);

//提交
async function submit() {
  if (form_data.value.username === "" || form_data.value.password === "") {
    return;
  }

  isLoading.value = true;
  error.value = false;
  error_msg.value = "";

  const formData = new FormData();
  formData.append("username", form_data.value.username);
  formData.append("password", form_data.value.password);
  formData.append("remember", form_data.value.remember);

  try {
    const response = await axios.post(login_url, formData, {
      headers: {
        "Content-Type": "multipart/form-data", // 指定使用 form-data 格式
      },
    });

    const token = response.data.access_token;
    //console.log(token);

    // 解析 JWT Token 内容
    const decodedToken = jwtDecode(token);
    console.log("解析后的 Token 内容:", decodedToken);

    // 存储token
    setToken(token);

    let userid =  decodedToken["sub"];
    emits("login",userid);    

    if (route.query && route.query['redirect']) {
      router.push({ path: route.query['redirect']});
    } else {
      router.push({ path: "/" });
    }
  } catch (error) {
    if (error.code == "ERR_NETWORK") {
      error_msg.value = "网络错误,无法连接到服务器。";
    } else if (error.code == "ERR_BAD_REQUEST") {
      if (error.response.status == 401) {
        error_msg.value = "用户名或密码错误。";
      }
    } else {
      error_msg.value = error.message;
    }
  }

  isLoading.value = false;
  if (error_msg.value != "") {
    error.value = true;
  }
}

JWT字符串可以解密

后台颁发的JWT token是经过签名的,但是签名的目的是防篡改,客户端收到这个token后,是可以解析出签名前的JWT token原文的,但是由于不知道签名JWT使用的密钥,修改后无法再签名并提交给服务端。

2. 服务端验证用户登录

以下代码基于FastAPI

python 复制代码
# 登录方法
@app.post("/token")
async def login_for_access_token(form_data: OAuth2PasswordRequestForm = Depends(),remember: bool|None=Body(None),log_details: None = Depends(log_request_details))-> Token:
    '''
    OAuth2PasswordRequestForm 是用以下几项内容声明表单请求体的类依赖项:

    username
    password
    scope、grant_type、client_id等可选字段。
    '''
    
    user = authenticate_user(form_data.username, form_data.password)
    if not user:
        raise HTTPException(
            status_code=status.HTTP_401_UNAUTHORIZED,
            detail="用户名或者密码错误",
            headers={"WWW-Authenticate": "Bearer"},
        )
    m = 0
    if remember:
        m = ACCESS_TOKEN_EXPIRE_MINUTES
        
    # 在JWT 规范中,sub 键的值是令牌的主题。
    access_token = create_access_token(data={"sub": user.username},encrypted_text=user.userid, expire_minutes=m)

    # 响应返回的内容应该包含 token_type。本例中用的是BearerToken,因此, Token 类型应为bearer。
    return Token(access_token=access_token, token_type="bearer")

增加remember参数实现30天内免登录

上述代码中,除了接收客户端的usernamepassword以外,还额外接收一个remember请求,remember参数用于实现30天内免登录,如果客户端选择30天内免登录,服务端将会颁发有效期为30天的JWT token

3. 服务端颁发JWT Token

JWT Token 的主要结构是:

  • sub 即主题,包含 用户昵称
  • sign 一段加密的信息,里面包含 userId和exp
  • exp 时间戳,单位为:秒

一个颁发后的token长这样子:

将userId加密后放在token里面的好处

每次服务器收到客户端的请求时,可以解析出来userId,这样可以很方便的通过它查询用户相关的信息返回给客户端;由于userId具有唯一性,所以用它处理会话也更方便。

python 复制代码
separator = "\u2016"

# 用于JWT签名。
SECRET_KEY = config['secret']["jwt_key"]
'''
注意,不要使用本例所示的密钥,因为它不安全。
'''

# 对JWT编码解码的算法。JWT不加密,任何人都能用它恢复原始信息。
ALGORITHM = "HS256"

DEFAULT_TOKEN_EXPIRE_MINUTES = config['token']["default_expires_time"]   #默认token过期时间

# 加密签名:userid + timestamp
def get_sign(encrypted_text,access_token_expires):
    t = str(math.floor(access_token_expires.timestamp()))
    #print(f"access_token_expires is {t}")
    src_text = encrypted_text + separator + t
    return encrypt(src_text) 

from datetime import datetime, timedelta, timezone
import jwt
from jwt.exceptions import InvalidTokenError,ExpiredSignatureError

# 生成JWT
def create_access_token(data: dict,encrypted_text: str=None, expire_minutes: int | None = None):
    to_encode = data.copy()
    if expire_minutes is not None and expire_minutes > 0:
        expires_delta = timedelta(minutes=expire_minutes) 
        expire = datetime.now(timezone.utc) + expires_delta
    else:
        expire = datetime.now(timezone.utc) + timedelta(minutes=DEFAULT_TOKEN_EXPIRE_MINUTES)
    if encrypted_text is not None and encrypted_text != "": #加密签名
        sign = get_sign(encrypted_text,expire)
        to_encode.update({"sign": sign})
    to_encode.update({"exp": expire})
    encoded_jwt = jwt.encode(to_encode, SECRET_KEY, algorithm=ALGORITHM)
    return encoded_jwt

4. 客户端管理 JWT token

客户端收到服务端颁发的 JWT token 后,可以存储在本地存储和会话中。

在本次会话,即未关闭浏览器之前,后续的请求都使用会话中存储的token。

以后再打开此系统时,可以从会话中取出token直接用。在这种情况下,客户端不必向服务端发送任何请求,也可以更新登录状态,显示 用户昵称。

当然,客户端在使用本地存储的token时,也应该校验一下是否过期。即便校验错误也没关系,因为服务端也会再次校验。

下面是客户端使用 JWT Token 的主要代码:

javascript 复制代码
import { reactive } from 'vue';
import { jwtDecode } from "jwt-decode";

const state = reactive({
  token: null,
});

const token_name = 'token_liupras';

const setToken = (token) => { 
  state.token = token;
  localStorage.setItem(token_name, token);
};


const getToken = () => {
  if (!state.token) {
    state.token = localStorage.getItem(token_name);
  }
  return state.token;
};

const getUserId =() => {
  const token = getToken();
  if (token) {
    const decodedToken = jwtDecode(token);

    // 校验过期时间
    let exp_str = decodedToken['exp'];    
    let exp = parseInt(exp_str);   
    let now = parseInt(Date.now()/1000);    //转换为秒
    if (exp > now) {
      let diffDays = Math.floor((exp-now) / (60 * 60 * 24));
      console.log("token还有"+diffDays+"天过期。");
      return decodedToken.sub;
    }else{
      return null;
    }    
  }else{
    return null;
  }
};

const logout = () => {
  state.token = null;
  localStorage.removeItem(token_name);
};

const checkLoggedIn = () => {
  return !!getUserId();
};

export {setToken, getToken, logout, checkLoggedIn,getUserId};

上述代码实现了客户端会话的管理功能

5. 客户端后续请求

在拥有了JWT Token 以后,客户端在发请求时,将它放在请求的Header中就可以了,我们可以在通用的请求方法中自动把它加上。

javascript 复制代码
import axios from 'axios';
import {getToken,logout} from '@/assets/js/auth';

const instance = axios.create({
  baseURL: 'http://localhost:8000',
  timeout: 90000,
});

// 请求拦截器:添加 Authorization 头
instance.interceptors.request.use(
  (config) => {
    const token = getToken();
    if (token) {
      config.headers.Authorization = `Bearer ${token}`;
    }
    return config;
  },
  (error) => Promise.reject(error)
);

// 响应拦截器:处理 401 错误
instance.interceptors.response.use(
  (response) => response,
  (error) => {
    if (error.response && error.response.status === 401) {
      logout();
    }
    return Promise.reject(error);
  }
);

export default instance;

6. 服务端验证客户端的token

使用 FastAPI 处理客户端请求时,可以使用依赖注入自动进行token验证。

python 复制代码
# 根据token获取当前登录的用户信息
# 该函数接收 str 类型的令牌,并返回 Pydantic 的 User 模型
async def get_current_user(token: str = Depends(oauth2_scheme)):
    '''
    安全和依赖注入的代码只需要写一次。各个端点可以使用同一个安全系统。
    '''
    credentials_exception = HTTPException(
        status_code=status.HTTP_401_UNAUTHORIZED,
        detail="Could not validate credentials",
        headers={"WWW-Authenticate": "Bearer"},
    )
    try:
        userid = decode_access_token(token)
        if userid is None or userid == "":
            raise credentials_exception
        user = get_user(username=userid)
        if user is None:
            raise credentials_exception
        return user
    except Exception:
        raise credentials_exception
    

# 获取当前登录用户信息,并检查是否禁用
async def get_current_active_user(current_user: User = Depends(get_current_user)):
    '''
    在端点中,只有当用户存在、通过身份验证、且状态为激活时,才能获得该用户信息。
    '''
    if current_user.disabled:
        raise HTTPException(status_code=400, detail="Inactive user")
    return current_user

# 获取用户信息
@app.get("/users/me")
async def read_users_me(current_user: Annotated[User, Depends(get_current_active_user)]):
    '''
    Depends 在依赖注入系统中处理安全机制。
    此处把 current_user 的类型声明为 Pydantic 的 User 模型,这有助于在函数内部使用代码补全和类型检查。
    get_current_user 依赖项从子依赖项 oauth2_scheme 中接收 str 类型的 toke。
    FastAPI 校验请求中的 Authorization 请求头,核对请求头的值是不是由 Bearer + 令牌组成, 并返回令牌字符串;如果没有找到 Authorization 请求头,或请求头的值不是 Bearer + 令牌。FastAPI 直接返回 401 错误状态码(UNAUTHORIZED)。
    '''
    return current_user

总结

以上的文字和代码阐述了如何基于OAuth2.0JWT实现安全完善的用户认证,欢迎指正。

查看完整代码


🪐祝好运🪐

相关推荐
ℋᙚᵐⁱᒻᵉ鲸落4 小时前
移动端滑动手势冲突:overflow: auto导致外层横向滑动失效
前端·javascript·css·vue.js·html
BullSmall4 小时前
第三方软件安全-Dependency‑Track Docker 部署
安全·docker·容器
AImatters5 小时前
国产算力底座,支撑油气储运数字化迈过关键分水岭
安全·cpu·算力·海光·油气储运
叠叠乐8 小时前
中国移动家庭云电脑window关闭所有安全脚本
安全
Jmyd01238 小时前
实训室的 3D 模型涉及肖像文物,数据安全与合规怎么做?
安全·3d·数据安全·虚拟实训
芯盾时代8 小时前
《金融业网络安全管理办法(征求意见稿)》全条款深度拆解(三)
网络·安全·网络安全
敢敢是只喵i9 小时前
一个本地 AI Agent 要操作多个门店或 SaaS 账号,应该怎样安全切换身份?
人工智能·安全·ai·系统架构·业界资讯
Acrel123410 小时前
DC800V 大规模落地,算力机房安全该如何保障
安全
用户500937683903910 小时前
热敏小票 Web 打印:我在 58/80mm 上折腾的两周
前端·vue.js
其实防守也摸鱼10 小时前
权限提升与横向移动:从内网渗透到域控的完整技术图谱
运维·服务器·数据库·安全·github·copilot·渗透