认证方案
有两种常见的认证方案,分别是基于Session的认证和基于Token的认证
基于Sessioon
认证流程
特点:
* 登录用户信息保存在服务端内存中,若访问量增加,单台节点压力会较大
* 随用户规模增大,若后台升级为集群,则需要解决集群中各服务器登录状态共享的问题。
基于ToKen
特点
* 登录状态保存在客户端,服务器没有存储开销
* 客户端发起的每个请求自身均携带登录状态,所以即使后台为集群,也不会面临登录状态共享的问题。
ToKen详解
我们所说的Token,通常指JWT(JSON Web TOKEN)。JWT是一种轻量级的安全传输方式,用于在两个实体之间传递信息,通常用于身份验证和信息传递。
JWT是一个字符串,如下图所示,该字符串由三部分组成,三部分由`.`分隔。三个部分分别被称为
`header`(头部)
Header部分是由一个JSON对象经过`base64url`编码得到的,这个JSON对象用于保存JWT 的类型(`typ`)、签名算法(`alg`)等元信息,例如
{ "alg": "HS256", "typ": "JWT" }
`payload`(负载)
也称为 Claims(声明),也是由一个JSON对象经过`base64url`编码得到的,用于保存要传递的具体信息。JWT规范定义了7个官方字段,如下:
* iss (issuer):签发人
* exp (expiration time):过期时间
* sub (subject):主题
* aud (audience):受众
* nbf (Not Before):生效时间
* iat (Issued At):签发时间
* jti (JWT ID):编号除此之外,我们还可以自定义任何字段,例如
{ "sub": "1234567890", "name": "John Doe", "iat": 1516239022 }
`signature`(签名)
由头部、负载和秘钥一起经过(header中指定的签名算法)计算得到的一个字符串,用于防止消息被篡改。
登录流程
验证码接口
本项目使用开源的验证码生成工具**EasyCaptcha**,其支持多种类型的验证码,例如gif、中文、算术等,并且简单易用,具体内容可参考其[官方文档](https://gitee.com/ele-admin/EasyCaptcha)。
导入相关依赖
开发接口
@Operation(summary = "获取图形验证码")
@GetMapping("login/captcha")
public Result<CaptchaVo> getCaptcha() {
CaptchaVo captchaVo = service.getCaptcha();
return Result.ok(captchaVo);
}
CaptchaVo getCaptcha();
@Override
public CaptchaVo getCaptcha() {
SpecCaptcha specCaptcha = new SpecCaptcha(130, 48, 4);
String code = specCaptcha.text().toLowerCase();
String key = RedisConstant.ADMIN_LOGIN_PREFIX + UUID.randomUUID(); // key值需遵循命名规范
stringRedisTemplate.opsForValue().set(key, code, RedisConstant.ADMIN_LOGIN_CAPTCHA_TTL_SEC, TimeUnit.SECONDS); // 60秒过期
return new CaptchaVo(specCaptcha.toBase64(), key);
}
登录接口
@Operation(summary = "登录")
@PostMapping("login")
public Result<String> login(@RequestBody LoginVo loginVo) {
String jwt = service.login(loginVo);
return Result.ok(jwt);
}
String login(LoginVo loginVo);
@Override
public String login(LoginVo loginVo) {
// 前端发送`username`、`password`、`captchaKey`、`captchaCode`请求登录。
// 判断`captchaCode`是否为空,若为空,则直接响应`验证码为空`;若不为空进行下一步判断。
if (loginVo.getCaptchaCode() == null) {
throw new LeaseException(ResultCodeEnum.ADMIN_CAPTCHA_CODE_NOT_FOUND);
}
// 根据`captchaKey`从Redis中查询之前保存的`code`,若查询出来的`code`为空,则直接响应`验证码已过期`;若不为空进行
String code = stringRedisTemplate.opsForValue().get(loginVo.getCaptchaKey());
if (code == null) {
throw new LeaseException(ResultCodeEnum.ADMIN_CAPTCHA_CODE_EXPIRED);
}
// 判断`captchaCode`和之前保存的`code`是否相同,若不相同,则直接响应`验证码错误`;若相同进行下一步判断。
if (!code.equals(loginVo.getCaptchaCode())) {
throw new LeaseException(ResultCodeEnum.ADMIN_CAPTCHA_CODE_ERROR);
}
// 判断`username`和之前保存的`username`是否相同,若不相同,则直接响应`账号不存在`;若相同进行下一步判断。
SystemUser systemUser = systemUserMapper.selectOneByUserName(loginVo.getUsername());
if (systemUser == null) {
throw new LeaseException(ResultCodeEnum.ADMIN_ACCOUNT_NOT_EXIST_ERROR);
}
// 查看用户状态,判断是否被禁用,若禁用,则直接响应`账号被禁`;若未被禁用,则进行下一步判断。
if (systemUser.getStatus() == BaseStatus.DISABLE) {
throw new LeaseException(ResultCodeEnum.ADMIN_ACCOUNT_DISABLED_ERROR);
}
// 判断`password`和之前保存的`password`是否相同,若不相同,则直接响应`账号或密码错误`;若相同进行下一步判断。
if (!systemUser.getPassword().equals(DigestUtils.md5Hex(loginVo.getPassword()))) {
throw new LeaseException(ResultCodeEnum.ADMIN_ACCOUNT_ERROR);
}
// 创建JWT,并响应给浏览器
return JwtUtil.createToken(systemUser.getId(), systemUser.getUsername());
}
JWT
登录接口需要为登录成功的用户创建并返回JWT,本项目使用开源的JWT工具**Java-JWT**,配置如下,具体内容可参考[官方文档](https://github.com/jwtk/jjwt/tree/0.11.2)。
1.导入依赖
2.开发工具类
public class JwtUtil {
private static SecretKey secretKey = Keys.hmacShaKeyFor("oUTzaoUGmKOzdHFx9eDSSZqtY32nugV6".getBytes()); //密钥
// 创建token
public static String createToken(Long userId, String username) {
String jwt = Jwts.builder() //创建jwt工厂
.setExpiration(new Date(System.currentTimeMillis() + 60 * 60 * 1000)) //设置过期时间
.setSubject("Login_User") //设置主题
.claim("userId", userId) //设置用户id"
.claim("username", username) //设置用户名
.signWith(secretKey, SignatureAlgorithm.HS256)//设置加密方式
.compact();
return jwt;
}
// 解析token
public static Claims parseTaken(String token) {
if (token == null) {
throw new LeaseException(ResultCodeEnum.ADMIN_LOGIN_AUTH);
}
try {
Jws<Claims> claimsJws = Jwts.parserBuilder()
.setSigningKey(secretKey)
.build()
.parseClaimsJws(token);
return claimsJws.getBody();
} catch (ExpiredJwtException e) {
throw new LeaseException(ResultCodeEnum.TOKEN_EXPIRED);
} catch (JwtException e) {
throw new LeaseException(ResultCodeEnum.TOKEN_INVALID);
}
}
}
配置拦截器
// 自定义拦截器
@Component
public class AuthenticationInterceptor implements HandlerInterceptor {
@Override
public boolean preHandle(HttpServletRequest request, HttpServletResponse response, Object handler) throws Exception {
String token = request.getHeader("access-token"); // 将jwt的key值设为access-token放入请求头中(该值需和前端商定一致)
Claims claims = JwtUtil.parseTaken(token);//解析前端的token是否符合规则,符合即登录,可放行
Long userId = claims.get("userId", Long.class);
String username = claims.get("username", String.class);
LoginUserHolder.setLoginUser(new LoginUser(userId, username)); // 放入登录用户信息,用于后续获取用户信息
return true;
}
@Override
public void afterCompletion(HttpServletRequest request, HttpServletResponse response, Object handler, Exception ex) throws Exception {
LoginUserHolder.clear(); // 清除登录用户信息
}
}
@Override
public void addInterceptors(InterceptorRegistry registry) {
registry
.addInterceptor(authenticationInterceptor) // 拦截器
.addPathPatterns("/admin/**") // 拦截所有/admin开头的请求
.excludePathPatterns("/admin/login/**"); // 排除/admin/login开头的请求
}
public class LoginUserHolder {
public static ThreadLocal<LoginUser> threadLocal = new ThreadLocal<>();
public static void setLoginUser(LoginUser loginUser) {
threadLocal.set(loginUser);
}
public static LoginUser getLoginUser() {
return threadLocal.get();
}
public static void clear() {
threadLocal.remove();
}
}