回顾一下JWT

基于JWT的认证流程

  • 用户在浏览器中输入用户名和密码,服务器通过密码校验后生成一个token并保存到数据库
  • 前端获取到token,存储到cookie或者local storage中,在后续的请求中都将带有这个token信息进行访问
  • 服务器获取token值,通过查找数据库判断当前token是否有效

安全性

  • JWT的payload使用的是base64编码的,因此在JWT中不能存储敏感数据。
  • 不同于session的信息是存在服务端的,session相对来说更安全。
  • 如果在JWT中存储了敏感信息,可以解码出来非常的不安全

性能

  • 经过编码之后JWT将非常长,cookie的限制大小一般是4k,cookie很可能放不下,所以JWT一般放在local storage里面。
  • 并且用户在系统中的每一次http请求都会把JWT携带在Header里面,HTTP请求的Header可能比Body还要大。
  • 而sessionId只是很短的一个字符串,因此使用JWT的HTTP请求比使用session的开销大得多

一次性

无状态是JWT的特点,但也导致了这个问题,JWT是一次性的。想修改里面的内容,就必须签发一个新的JWT

  • 无法废弃
    • 一旦签发一个JWT,在到期之前就会始终有效,无法中途废弃。
    • 若想废弃,一种常用的处理手段是结合redis。
  • 续签
    • 如果使用JWT做会话管理,传统的cookie续签方案一般都是框架自带的,session有效期30分钟,30分钟内如果有访问,有效期被刷新至30分钟。
    • 一样的道理,要改变JWT的有效时间,就要签发新的JWT。
    • 最简单的一种方式是每次请求刷新JWT,即每个HTTP请求都返回一个新的JWT。
    • 这个方法不仅暴力不优雅,而且每次请求都要做JWT的加密解密,会带来性能问题。
    • 另一种方法是在redis中单独为每个JWT设置过期时间,每次访问时刷新JWT的过期时间

Token过期

  • 想一下,当你正在用电脑录入信息或者抢购东西时,突然弹出登录信息已过期提示…
    在这里插入图片描述

影响

当一个token过期时,可能会带来以下影响:

  1. 无效使用:过期的token无法再被使用,因此持有者无法通过该token进行交易、合约执行或获取相关服务。

  2. 安全性提升:过期的token不再有效,可以防止被盗或滥用。这有助于确保账户和资金的安全。

  3. 用户体验降低:如果用户忘记或不知道token已经过期,他们可能会尝试使用无效的token,导致交易失败或无法访问所需的服务。这可能会降低用户的体验和满意度。

  4. 重新认证:一旦token过期,用户可能需要重新进行身份验证或获取新的有效token,以继续使用相关服务。这可能会增加一些额外的步骤和麻烦。

  5. 数据或权益的丢失:某些情况下,过期的token可能与特定的数据或权益相关联。如果没有及时处理过期token,用户可能会失去对这些数据或权益的访问。这可能会对用户的个人或商业利益产生负面影响。

  6. 违约或合同终止:在某些情况下,过期token可能与某些合同或协议的有效性相关。一旦token过期,可能会导致违约或合同的终止。

总的来说,过期的token可能会导致无效使用、安全性提升、用户体验降低、重新认证、数据或权益的丢失以及违约或合同终止等影响。因此,对于token的持有者和相关服务提供商来说,管理和处理过期token是很重要的。

解决

智障思路

  • token时间长点?避免不了突然失效的情景
  • token永不过期?不安全,家被偷了都没发现

分析

  • 上述两种情况必然都不可行,现在问题明确为:token需要设置过期时间,但是时间总有上限。
  • 因此问题的解决点就是如何自动延长token的时间,那么也有两种思路
    1. token定时检查续期:也就是在快过期时自动续期,比如还剩半小时的时候,检测到时间不足自动续期
    2. 双token。生成两个token:accessToken(验证)和refreshToken(刷新)
      • 验证token过期时间短些,刷新token设置长一点的过期时间;
      • 接口请求调用验证token,验证token过期后,如果有刷新token并且没过期,生成一个验证token返回给前端,后续调用新的验证token即可。

token定时检查续期

思路分析

  1. jwt工具类中生成的token中不带有过期时间,token的过期时间由redis进行管理
  2. 用户通过认证后,生成token,并保存到redis中(两份数据)
    • 数据1:用户ID作为key,token作为值
    • 数据2:token作为key,用户信息作为值
  3. 登出时将对应的key删除即可
  4. 更新用户密码时需要重新生成新的token,并将新的token返回给前端,由前端更新保存在local storage中的token,同时更新存储在redis中的token,这样实现可以避免用户重新登陆,用户体验感不至于太差。当然也可以让用户更新密码后自动跳转到重新登录页面。
  5. 拦截器中主要做两件事,一是对token进行校验,二是判断token是否需要进行续期 token校验:
    • 判断id对应的token是否不存在,不存在则token过期
    • 若token存在则比较token是否一致,保证同一时间只有一个用户操作
    • token自动续期: 为了不频繁操作redis,只有当离过期时间只有30分钟时才更新过期时间

大致代码

	if (RedisUtil.getExpireTime(user.getId()) < 1 * 60 * 30) {
		RedisUtil.set(userTokenDTO.getId(), token);
		log.error("token即将过期,更新token信息, id :{}, 用户ID :{}", user.getId(), token);
	}

问题

  • 该方案确实完成了自动续期,也可以及时增加时间
  • 但是比如设置的是不足半小时自动续期,那我如果是在剩余35分钟的时候,触发了一次请求,下一次操作是四十分钟后了,这个时候已经过期了
  • 而由于上次检测到该token是四十分钟之前,导致没有及时续期,登录信息已过期

双token【重点】

思路分析

  1. 登录成功以后,后端返回 access_token 和 refresh_token,客户端缓存此两种token;
  2. 使用 access_token 请求接口资源,成功则调用成功;如果token超时,客户端携带 refresh_token 调用token刷新接口获取新的 access_token;
  3. 后端接受刷新token的请求后,检查 refresh_token 是否过期。如果过期,拒绝刷新,客户端收到该状态后,跳转到登录页;如果未过期,生成新的 access_token 返回给客户端。
  4. 客户端携带新的 access_token 重新调用上面的资源接口。
  5. 客户端退出登录或修改密码后,注销旧的token,使 access_token 和 refresh_token 失效,同时清空客户端的 access_token 和 refresh_toke。
补充

在实际的生产环境中,为了保证系统的安全性,你可能需要考虑到以下几点:

  1. Token也可以在服务端保存一份,比如存到Redis中,并对前端传来的token与redis中的比较,这样可以实现服务端主动让token失效,比如从redis删除token即可。
  2. 考虑到用户的session状态,当用户退出登录或者修改密码后,需要把保存在服务端的refresh token删除或者置为无效。
  3. 应用 HTTPS 协议以保护你的 token 不被截获。
  4. 使用黑名单机制,当用户的 token 被盗或者用户退出登录后,你可以把这个 token 添加到黑名单中,防止它再次被用于请求。
  5. 考虑到服务的可用性,你可能需要把 token 保存在像Redis这样的内存数据库中,以提升性能。

微信网页授权是通过OAuth2.0机制实现的,也使用了双token方案。

微信网页授权方案
  1. 用户在第三方应用的网页上完成微信授权以后,第三方应用可以获得 code(授权码)。code的超时时间为10分钟,一个code只能成功换取一次access_token即失效。
  2. 第三方应用通过code获取网页授权凭证access_token和刷新凭证 refresh_token。
  3. access_token是调用授权关系接口的调用凭证,由于access_token有效期(2个小时)较短,当access_token超时后,可以使用refresh_token进行刷新。
  4. refresh_token拥有较长的有效期(30天),当refresh_token失效的后,需要用户重新授权。

实现

1.依赖
        <!-- JSON 解析器和生成器 -->
        <dependency>
            <groupId>com.alibaba</groupId>
            <artifactId>fastjson</artifactId>
            <version>1.2.83</version>
        </dependency>
        <dependency>
            <groupId>io.jsonwebtoken</groupId>
            <artifactId>jjwt</artifactId>
            <version>0.9.1</version>
        </dependency>
        <dependency>
            <groupId>org.springframework.boot</groupId>
            <artifactId>spring-boot-starter-data-redis</artifactId>
        </dependency>
        ... 其他省略了
2.配置
# JWT ??
jwt:
  secret: Y28Ijg521FgN31ZgpD1hZpOYd8fTMrZwNcMgds+D91I= # ????
  expire: 1800 # token???? S   30??
  refreshExpire: 86400 # token???? S  1?
spring:
  redis:
    host: localhost
    port: 6379
    password: 123456
    database: 14
# 省略其他数据库、mybatis...配置
3.拦截器及配置
package com.kgc.interceptor;

import com.kgc.pojo.User;
import com.kgc.utils.JwtTools;
import lombok.extern.slf4j.Slf4j;
import org.springframework.stereotype.Component;
import org.springframework.util.StringUtils;
import org.springframework.web.servlet.HandlerInterceptor;

import javax.annotation.Resource;
import javax.servlet.http.HttpServletRequest;
import javax.servlet.http.HttpServletResponse;

/**
 * JWTInterceptor是一个拦截器,用于验证请求头中的JWT令牌是否有效。
 * 当有请求进入时,该拦截器会首先从请求头中获取令牌,并尝试验证其有效性。
 * 如果令牌验证成功,则放行请求;否则,拦截请求并返回相应的错误信息。
 * @author: zjl
 * @datetime: 2024/5/31
 * @desc: 复兴Java,我辈义不容辞
 */
@Component
@Slf4j
public class JWTInterceptor implements HandlerInterceptor {
    @Resource
    private JwtTools jwtTools;

    @Override
    public boolean preHandle(HttpServletRequest request, HttpServletResponse response, Object handler) {
        // 获取token
        String token = request.getHeader("Authorization"); //token
        if (StringUtils.isEmpty(token)) {
            token = request.getParameter("Authorization"); //token
        }
        if (StringUtils.isEmpty(token)) {
            // 只是简单DEMO,这里直接返回false,可以自己进行添加
            log.error("token 不能为空!");
            return false;
        }

        // 判断token是否超时
        if (jwtTools.isTokenExpired(token)) {
            log.error("token 已失效!");
            return false;
        }

        // 判断 token 是否已在黑名单
        if (jwtTools.checkBlacklist(token)) {
            log.error("token 已被加入黑名单!");
            return false;
        }

        // 获取用户信息
        User user = jwtTools.getUserToken(token);
        // 通过用户信息去判断用户状态,等业务

        return true;
    }
}
package com.kgc.config;

/**
 * @author: zjl
 * @datetime: 2024/5/31
 * @desc: 复兴Java,我辈义不容辞
 */

import com.kgc.interceptor.JWTInterceptor;
import org.springframework.context.annotation.Configuration;
import org.springframework.web.servlet.config.annotation.InterceptorRegistry;
import org.springframework.web.servlet.config.annotation.WebMvcConfigurer;

import javax.annotation.Resource;

/**
 * InterceptorConfig 是一个配置类,用于添加拦截器。
 * 在这个类中,我们可以配置需要拦截的接口路径以及排除不需要拦截的接口路径。
 * 在这个例子中,我们添加了JWTInterceptor拦截器来对请求进行token验证,
 * 并设置了"/user/test"接口需要进行验证,而"/user/login"接口则被排除在验证之外,即所有用户都放行登录接口。
 */
@Configuration
public class InterceptorConfig implements WebMvcConfigurer {
    @Resource
    private JWTInterceptor jwtInterceptor;
    /**
     * 添加拦截器配置
     */
    @Override
    public void addInterceptors(InterceptorRegistry registry) {
        registry.addInterceptor(jwtInterceptor)
                .addPathPatterns("/user/*")         // 对/user下其他接口进行token验证
                .excludePathPatterns("/user/login");  // 所有用户都放行登录接口
    }
}
4.其他类

实体类

@Data
@NoArgsConstructor
@ToString
@AllArgsConstructor
public class User {
    private int id;
    private String userCode;
    private String userName;
    private String userPassword;
    private String phone;
}

service

@Service
@Slf4j
public class UserService {
    @Resource
    private UserMapper userMapper;
    public User login(String userCode,String userPassword){
        User user = userMapper.selectUserByUserCode(userCode);
        if(user!=null && userPassword.equals(user.getUserPassword())){
            return user;
        }
        return null;
    }
}

mapper

public interface UserMapper {
    @Select("SELECT * FROM SMBMS_USER WHERE USERCODE=#{userCode}")
    User selectUserByUserCode(String userCode);
}

封装返回结果

@Data
public class Result<T> {
    private Integer code;
    private String msg;
    private T data;
    public Result(ResultTypeEnum resultTypeEnum, T data) {
        this.code = resultTypeEnum.getCode();
        this.msg = resultTypeEnum.getMsg();
        this.data = data;
    }
    public Result(ResultTypeEnum resultTypeEnum) {
        this.code = resultTypeEnum.getCode();
        this.msg = resultTypeEnum.getMsg();
    }
}
@Getter
@AllArgsConstructor
public enum ResultTypeEnum {

    SUCCESS(200, "请求处理成功!"),
    LOGINFAIL(0, "登录失败!"),

    UN_AUTHORIZED(401, "未授权"),
    NOT_FOUND(404, "无法找到资源"),
    NOT_ALLOWED(405, "禁止请求该资源"),
    PARAMS_NOT_NULL(406, "参数缺失,请检查参数!"),
    PARAMS_NOT_VALID(407, "参数校验失败,请检查参数!"),
    VALID_ERROR(407, "参数校验失败,请检查参数!"),
    OPERATION_TYPE_ERROR(408, "操作类型错误"),

    TOKEN_IS_NULL(10001, "token 不能为空"),
    TOKEN_INVALID(10002, "token 已失效"),
    TOKEN_BLACKLIST(10003, "token 已被加入黑名单"),
    USER_STATE_DISABLE(10004, "用户已被禁用,请联系管理员"),
    USER_STATE_DELETE(10005, "用户已被删除,请联系管理员"),
    FAIL(9001, "请求处理失败!");
    private Integer code;
    private String msg;
}

常量类

public class Constants {
    /**
     * 黑名单redis储存前缀
     */
    public static final String TOKEN_BLACKLIST_PREFIX = "blacklist_";
    public static final String MAC = "mac";
    public static final String OS_NAME = "os.name";
}

redis工具类

@Component
public class RedisKeyUtil {
    private static StringRedisTemplate redisTemplate;
    @Autowired
    public void setRedisTemplate(StringRedisTemplate redisTemplate) {
        RedisKeyUtil.redisTemplate = redisTemplate;
    }

    /**
     * 是否存在key
     *
     * @param key
     * @return
     */
    public static Boolean hasKey(String key) {
        return redisTemplate.hasKey(key);
    }
}
@Component
public class RedisStringUtil {
    private static StringRedisTemplate redisTemplate;
    @Autowired
    public void setRedisTemplate(StringRedisTemplate redisTemplate) {
        RedisStringUtil.redisTemplate = redisTemplate;
    }
    /** -------------------string相关操作--------------------- */
    /**
     * 设置指定 key 的值
     *
     * @param key
     * @param value
     */
    public static void set(String key, String value) {
        redisTemplate.opsForValue().set(key, value);
    }

    /**
     * 获取指定 key 的值
     *
     * @param key
     * @return
     */
    public static String get(String key) {
        return redisTemplate.opsForValue().get(key);
    }


    /**
     * 将值 value 关联到 key ,并将 key 的过期时间设为 timeout
     *
     * @param key
     * @param value
     * @param timeout 过期时间
     * @param unit    时间单位, 天:TimeUnit.DAYS 小时:TimeUnit.HOURS 分钟:TimeUnit.MINUTES
     *                秒:TimeUnit.SECONDS 毫秒:TimeUnit.MILLISECONDS
     */
    public static void setEx(String key, String value, long timeout, TimeUnit unit) {
        redisTemplate.opsForValue().set(key, value, timeout, unit);
    }
}
5.token映射类
@Data
public class UserToken {
    private String accessToken;
    private String refreshToken;
}
6.jwt工具类
package com.kgc.utils;

/**
 * @author: zjl
 * @datetime: 2024/5/31
 * @desc: 复兴Java,我辈义不容辞
 */
import com.alibaba.fastjson.JSONObject;
import com.kgc.dto.UserToken;
import com.kgc.pojo.User;
import com.kgc.utils.redis.RedisKeyUtil;
import com.kgc.utils.redis.RedisStringUtil;
import io.jsonwebtoken.Claims;
import io.jsonwebtoken.Jwts;
import io.jsonwebtoken.SignatureAlgorithm;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.stereotype.Component;

import java.util.Date;
import java.util.concurrent.TimeUnit;
@Component
public class JwtTools {
    @Value("${jwt.secret}")
    private   String secret;
    @Value("${jwt.expire}")
    private  Integer tokenExpire;
    @Value("${jwt.refreshExpire}")
    private  Integer refreshExpire;

    /**
     * 创建 刷新令牌 与 访问令牌 关联关系
     *
     * @param userToken
     * @param refreshTokenExpireDate
     */
    public void tokenAssociation(UserToken userToken, Date refreshTokenExpireDate) {
        Long time = (refreshTokenExpireDate.getTime() - System.currentTimeMillis()) / 1000 + 100;
        RedisStringUtil.setEx(userToken.getRefreshToken(), userToken.getAccessToken(), time, TimeUnit.SECONDS);
    }

    /**
     * 根据 刷新令牌 获取 访问令牌
     *
     * @param refreshToken
     */
    public String getAccessTokenByRefresh(String refreshToken) {
        Object value = RedisStringUtil.get(refreshToken);
        return value == null ? null : String.valueOf(value);
    }


    /**
     * 添加至黑名单
     *
     * @param token
     * @param expireTime
     */
    public void addBlacklist(String token, Date expireTime) {
        Long expireTimeLong = (expireTime.getTime() - System.currentTimeMillis()) / 1000 + 100;
        RedisStringUtil.setEx(getBlacklistPrefix(token), "1", expireTimeLong,TimeUnit.SECONDS);
    }

    /**
     * 校验是否存在黑名单
     *
     * @param token
     * @return true 存在 false不存在
     */
    public Boolean checkBlacklist(String token) {
        return RedisKeyUtil.hasKey(getBlacklistPrefix(token));
    }

    /**
     * 获取黑名单前缀
     * @param token
     * @return
     */
    public String getBlacklistPrefix(String token) {
        return Constants.TOKEN_BLACKLIST_PREFIX + token;
    }


    /**
     * 获取 token 信息
     * @return
     */
    public UserToken createToekns(User user) {
        Date nowDate = new Date();
        Date accessTokenExpireDate = new Date(nowDate.getTime() + tokenExpire * 1000);
        Date refreshTokenExpireDate = new Date(nowDate.getTime() + refreshExpire * 1000);

        UserToken userToken = new UserToken();
        userToken.setAccessToken(createToken(user, nowDate, accessTokenExpireDate));
        userToken.setRefreshToken(createToken(user, nowDate, refreshTokenExpireDate));

        // 创建 刷新令牌 与 访问令牌 关联关系
        tokenAssociation(userToken, refreshTokenExpireDate);
        return userToken;
    }

    /**
     * 生成token
     * @return
     */
    public String createToken(User user, Date nowDate, Date expireDate) {
        return Jwts.builder()
                .setHeaderParam("typ", "JWT")
                .setSubject(JSONObject.toJSONString(user))
                .setIssuedAt(nowDate)
                .setExpiration(expireDate)
                .signWith(SignatureAlgorithm.HS512, secret)
                .compact();
    }

    /**
     * 获取 token 中注册信息
     *
     * @param token
     * @return
     */
    public Claims getTokenClaim(String token) {
        try {
            return Jwts.parser().setSigningKey(secret).parseClaimsJws(token).getBody();
        } catch (Exception e) {
            return null;
        }
    }

    /**
     * 验证 token 是否过期失效
     *
     * @param token
     * @return true 过期 false 未过期
     */
    public Boolean isTokenExpired(String token) {
        try {
            return getExpirationDate(token).before(new Date());
        }catch (Exception e){
            return true;
        }
    }

    /**
     * 获取 token 失效时间
     *
     * @param token
     * @return
     */
    public Date getExpirationDate(String token) {
        return getTokenClaim(token).getExpiration();
    }


    /**
     * 获取 token 发布时间
     *
     * @param token
     * @return
     */
    public Date getIssuedAtDate(String token) {
        return getTokenClaim(token).getIssuedAt();
    }


    /**
     * 获取用户信息
     *
     * @param token
     * @return
     */
    public User getUserToken(String token) {
        String subject = getTokenClaim(token).getSubject();
        User user = JSONObject.parseObject(subject, User.class);
        return user;
    }

    /**
     * 获取用户名
     * @param token
     * @return
     */
    public String getUserName(String token) {
        User user = getUserToken(token);
        return user.getUserName();
    }

    /**
     * 获取用户Id
     *
     * @param token
     * @return
     */
    public int getUserId(String token) {
        User user = getUserToken(token);
        return user.getId();
    }
}
7.controller类
package com.kgc.controller;

import com.kgc.dto.UserToken;
import com.kgc.pojo.User;
import com.kgc.service.UserService;
import com.kgc.utils.JwtTools;
import com.kgc.vo.Result;
import com.kgc.vo.ResultTypeEnum;
import lombok.extern.slf4j.Slf4j;
import org.springframework.util.StringUtils;
import org.springframework.web.bind.annotation.PathVariable;
import org.springframework.web.bind.annotation.PostMapping;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RestController;

import javax.annotation.Resource;

/**
 * @author: zjl
 * @datetime: 2024/5/31
 * @desc: 复兴Java,我辈义不容辞
 */
@RestController
@Slf4j
@RequestMapping("/user")
public class UserController {
    @Resource
    private UserService userService;
    @Resource
    private JwtTools jwtTools;
    /**
     * 登录
     * @return
     */
    @RequestMapping("/login")
    public Result<UserToken> login(String userCode,String userPassword) {
        User user = userService.login(userCode, userPassword);
        if(user==null){
            return new Result<>(ResultTypeEnum.LOGINFAIL);
        }
        // 生成Token
        UserToken userToken = jwtTools.createToekns(user);
        return new Result<>(ResultTypeEnum.SUCCESS, userToken);
    }
    @RequestMapping("/test")
    public String test() {
        return "test";
    }

    /**
     * 刷新令牌
     * @param refreshToken
     * @return
     */
    @RequestMapping("/refreshToken/{refreshToken}")
    public Result<UserToken> refreshToken(@PathVariable("refreshToken") String refreshToken) {
        // 判断token是否超时
        if (jwtTools.isTokenExpired(refreshToken)) {
            return new Result<>(ResultTypeEnum.TOKEN_INVALID);
        }
        // 刷新令牌 放入黑名单
        jwtTools.addBlacklist(refreshToken, jwtTools.getExpirationDate(refreshToken));
        // 访问令牌 放入黑名单
        String odlAccessToken = jwtTools.getAccessTokenByRefresh(refreshToken);
        if (!StringUtils.isEmpty(odlAccessToken)) {
            jwtTools.addBlacklist(odlAccessToken, jwtTools.getExpirationDate(odlAccessToken));
        }
        // 生成新的 访问令牌 和 刷新令牌
        User user = jwtTools.getUserToken(refreshToken);
        // 生成Token
        UserToken userToken = jwtTools.createToekns(user);
        return new Result<>(ResultTypeEnum.TOKEN_INVALID, userToken);
    }


    /**
     * 登出
     * @return
     */
    @PostMapping("/logOut/{token}")
    public Result logOut(@PathVariable("token") String token) {
        // 放入黑名单
        jwtTools.addBlacklist(token, jwtTools.getExpirationDate(token));
        return new Result<>(ResultTypeEnum.SUCCESS);
    }

    /**
     * 注销
     * @return
     */
    @PostMapping("/logOff/{token}")
    public Result logOff(@PathVariable("token") String token) {
        // 修改用户状态
        // 放入黑名单
        jwtTools.addBlacklist(token, jwtTools.getExpirationDate(token));
        return new Result<>(ResultTypeEnum.SUCCESS);
    }

}
8.测试
  • 登录(认证通过不通过的)
  • 访问test接口(带不带token的,带正确不正确token的)
  • 刷新接口,要带正确token

总结

token自动续期方式优点缺点
token定时检查续期方便实现,只需后端更改即可存在未及时续期情况
双token验证效率更高,适用的特殊情况更多需要前后端协调更改

双token流程图

在这里插入图片描述

Logo

开放原子开发者工作坊旨在鼓励更多人参与开源活动,与志同道合的开发者们相互交流开发经验、分享开发心得、获取前沿技术趋势。工作坊有多种形式的开发者活动,如meetup、训练营等,主打技术交流,干货满满,真诚地邀请各位开发者共同参与!

更多推荐