登录管理
背景知识
1. 认证方案概述
有两种常见的认证方案,分别是基于Session的认证和基于Token的认证,下面逐一进行介绍
-
基于Session
基于Session的认证流程如下图所示
该方案的特点
- 登录用户信息
保存在服务端内存
(Session对象)中,若访问量增加,单台节点压力会较大 - 随用户规模增大,若后台升级为集群,则
需要解决集群中各服务器登录状态共享
的问题。
- 登录用户信息
这里的集群中各服务器登录状态共享问题,我们用一个例子来演示:
假设一个电商网站使用了一个由三台服务器组成的集群来处理用户请求。当用户在浏览商品、添加购物车和结账时,这些请求可能会被负载均衡器分配到不同的服务器上。如果没有共享登录状态的机制:
-用户在服务器A上登录,并添加商品到购物车。
-用户的下一个请求被分配到服务器B,但服务器B没有用户的登录状态信息,导致用户被视为未登录状态,购物车数据丢失。
-
基于Token
基于Token的认证流程如下图所示
该方案的特点
- 登录状态
保存在客户端
(Token),服务器没有存储开销 - 客户端发起的每个
请求自身均携带登录状态
,所以即使后台为集群,也不会面临登录状态共享
的问题。
- 登录状态
2. Token详解
本项目采用基于Token的登录方案,下面详细介绍Token这一概念。
我们所说的Token,通常指JWT(JSON Web TOKEN)。JWT是一种轻量级的安全传输方式,用于在两个实体之间传递信息,通常用于身份验证和信息传递。
JWT是一个字符串,如下图所示,该字符串由三部分组成,三部分由.
分隔。三个部分分别被称为
header
(头部)payload
(负载)signature
(签名)
各部分的作用如下
-
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中指定的签名算法)计算得到的一个字符串,用于防止消息被篡改。
登录流程
后台管理系统的登录流程如下图所示
UUID(Universally Unique Identifier,通用唯一标识符)保证了每个验证码请求的唯一性。即使同时有多个用户请求验证码,UUID也能确保每个用户的请求对应唯一的验证码,避免了重复和冲突。UUID使得每次生成的验证码都是独立的,即使是同一个用户在不同时间请求验证码,也会生成不同的UUID,避免验证码被重用或篡改。
根据上述登录流程,可分析出,登录管理共需三个接口,分别是获取图形验证码、登录、获取登录用户个人信息,除此之外,我们还需为所有受保护的接口增加验证JWT合法性的逻辑,这一功能可通过HandlerInterceptor
来实现。
接口开发
首先在LoginController
中注入LoginService
,如下
@Tag(name = "后台管理系统登录管理")
@RestController
@RequestMapping("/admin")
public class LoginController {
@Autowired
private LoginService loginService;
}
1. 获取图形验证码
-
查看响应的数据结构
查看web-admin模块下的
com.atguigu.lease.web.admin.vo.login.CaptchaVo
,内容如下
@Data
@Schema(description = "图像验证码")
@AllArgsConstructor
public class CaptchaVo {
@Schema(description="验证码图片信息")
private String image;
@Schema(description="验证码key")
private String key;
}
配置所需依赖
-
验证码生成工具
本项目使用开源的验证码生成工具EasyCaptcha,其支持多种类型的验证码,例如gif、中文、算术等,并且简单易用,具体内容可参考其官方文档。
在common模块的pom.xml文件中增加如下内容
<dependency>
<groupId>com.github.whvcse</groupId>
<artifactId>easy-captcha</artifactId>
</dependency>
Redis
在common模块的pom.xml中增加如下内容
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-data-redis</artifactId>
</dependency>
在application.yml
中增加如下配置
spring:
data:
redis:
host: localhost
port: 6379
database: 0
注意:上述host
和port
需根据实际情况进行修改
编写Controller层逻辑
在LoginController
中增加如下内容
@Operation(summary = "获取图形验证码")
@GetMapping("login/captcha")
public Result<CaptchaVo> getCaptcha() {
CaptchaVo captcha = loginService.getCaptcha();
return Result.ok(captcha);
}
编写Service层逻辑
- 在
LoginService
中增加如下内容
CaptchaVo getCaptcha();
- 在
LoginServiceImpl
中增加如下内容
@Autowired
private StringRedisTemplate redisTemplate;
@Override
public CaptchaVo getCaptcha() {
// 创建一个特定规格的验证码对象,规格为130x48像素,包含4个字符
SpecCaptcha specCaptcha = new SpecCaptcha(130, 48, 4);
// 生成验证码文本,转换为小写以避免大小写敏感问题
String code = specCaptcha.text().toLowerCase();
// 生成一个唯一的键来存储验证码,键的前缀标识是“admin:login”
String key = RedisConstant.ADMIN_LOGIN_PREFIX + UUID.randomUUID();
// 将验证码文本存储在Redis中,设置过期时间为登录验证码的预定义生存时间
stringRedisTemplate.opsForValue().set(key, code, RedisConstant.ADMIN_LOGIN_CAPTCHA_TTL_SEC , TimeUnit.SECONDS);
// 返回一个包含验证码图像Base64编码和存储键的实体
return new CaptchaVo(specCaptcha.toBase64(), key);
}
知识点:
-
本项目Reids中的key遵循以下命名规范:项目名:功能模块名:其他,例如
admin:login:123456
-
spring-boot-starter-data-redis
已经完成了StringRedisTemplate
的自动配置,我们直接注入即可。 -
为方便管理,可以将Reids相关的一些值定义为常量,例如key的前缀、TTL时长,内容如下。大家可将这些常量统一定义在一个RedisConstant类中
public class RedisConstant {
public static final String ADMIN_LOGIN_PREFIX = "admin:login:";
public static final Integer ADMIN_LOGIN_CAPTCHA_TTL_SEC = 60;
public static final String APP_LOGIN_PREFIX = "app:login:";
public static final Integer APP_LOGIN_CODE_RESEND_TIME_SEC = 60;
public static final Integer APP_LOGIN_CODE_TTL_SEC = 60 * 10;
public static final String APP_ROOM_PREFIX = "app:room:";
}
2. 登录接口
-
登录校验逻辑
用户登录的校验逻辑分为三个主要步骤,分别是校验验证码,校验用户状态和校验密码,具体逻辑如下
- 前端发送
username
、password
、captchaKey
、captchaCode
请求登录。 - 判断
captchaCode
是否为空,若为空,则直接响应验证码为空
;若不为空进行下一步判断。 - 根据
captchaKey
从Redis中查询之前保存的code
,若查询出来的code
为空,则直接响应验证码已过期
;若不为空进行下一步判断。 - 比较
captchaCode
和code
,若不相同,则直接响应验证码不正确
;若相同则进行下一步判断。 - 根据
username
查询数据库,若查询结果为空,则直接响应账号不存在
;若不为空则进行下一步判断。 - 查看用户状态,判断是否被禁用,若禁用,则直接响应
账号被禁
;若未被禁用,则进行下一步判断。 - 比对
password
和数据库中查询的密码,若不一致,则直接响应账号或密码错误
,若一致则进行入最后一步。 - 创建JWT,并响应给浏览器。
- 前端发送
-
接口逻辑实现
-
查看请求数据结构
查看web-admin模块下的
com.atguigu.lease.web.admin.vo.login.LoginVo
,具体内容如下
-
@Data
@Schema(description = "后台管理系统登录信息")
public class LoginVo {
@Schema(description="用户名")
private String username;
@Schema(description="密码")
private String password;
@Schema(description="验证码key")
private String captchaKey;
@Schema(description="验证码code")
private String captchaCode;
}
配置所需依赖
登录接口需要为登录成功的用户创建并返回JWT,本项目使用开源的JWT工具Java-JWT,配置如下,具体内容可参考官方文档。
-
引入Maven依赖
在common模块的pom.xml文件中增加如下内容
<dependency>
<groupId>io.jsonwebtoken</groupId>
<artifactId>jjwt-api</artifactId>
</dependency>
<dependency>
<groupId>io.jsonwebtoken</groupId>
<artifactId>jjwt-impl</artifactId>
<scope>runtime</scope>
</dependency>
<dependency>
<groupId>io.jsonwebtoken</groupId>
<artifactId>jjwt-jackson</artifactId>
<scope>runtime</scope>
</dependency>
创建JWT工具类
在common模块下创建com.atguigu.lease.common.utils.JwtUtil
工具类,内容如下
public class JwtUtil {
private static long tokenExpiration = 60 * 60 * 1000L;
private static SecretKey tokenSignKey = Keys.hmacShaKeyFor("M0PKKI6pYGVWWfDZw90a0lTpGYX1d4AQ".getBytes());
public static String createToken(Long userId, String username) {
String token = Jwts.builder().
setSubject("USER_INFO").
setExpiration(new Date(System.currentTimeMillis() + tokenExpiration)).
claim("userId", userId).
claim("username", username).
signWith(tokenSignKey).
compact();
return token;
}
}
编写Controller层逻辑
在LoginController
中增加如下内容
@Operation(summary = "登录")
@PostMapping("login")
public Result<String> login(@RequestBody LoginVo loginVo) {
String token = loginService.login(loginVo);
return Result.ok(token);
}
编写Service层逻辑
- 在
LoginService
中增加如下内容
String login(LoginVo loginVo);
在LoginServiceImpl
中增加如下内容
@Override
public String login(LoginVo loginVo) {
//1.判断是否输入了验证码
if (!StringUtils.hasText(loginVo.getCaptchaCode())) {
throw new LeaseException(ResultCodeEnum.ADMIN_CAPTCHA_CODE_NOT_FOUND);
}
//2.校验验证码
String code = redisTemplate.opsForValue().get(loginVo.getCaptchaKey());
if (code == null) {
throw new LeaseException(ResultCodeEnum.ADMIN_CAPTCHA_CODE_EXPIRED);
}
if (!code.equals(loginVo.getCaptchaCode().toLowerCase())) {
throw new LeaseException(ResultCodeEnum.ADMIN_CAPTCHA_CODE_ERROR);
}
//3.校验用户是否存在
LambdaQueryWrapper<SystemUser> systemUserLambdaQueryWrapper = new LambdaQueryWrapper<>();
systemUserLambdaQueryWrapper.eq(SystemUser::getUsername, loginVo.getUsername());
SystemUser systemUser = systemUserMapper.selectOne(systemUserLambdaQueryWrapper);
if (systemUser == null) {
throw new LeaseException(ResultCodeEnum.ADMIN_ACCOUNT_NOT_EXIST_ERROR);
}
//4.校验用户是否被禁
if (systemUser.getStatus() == BaseStatus.DISABLE) {
throw new LeaseException(ResultCodeEnum.ADMIN_ACCOUNT_DISABLED_ERROR);
}
//5.校验用户密码,这里不要忘记对密码进行处理,数据库中的密码都是经过加密处理的
if (!systemUser.getPassword().equals(DigestUtils.md5Hex(loginVo.getPassword()))) {
throw new LeaseException(ResultCodeEnum.ADMIN_ACCOUNT_ERROR);
}
//6.创建并返回TOKEN
return JwtUtil.createToken(systemUser.getId(), systemUser.getUsername());
}
写完之后,启动项目却接收到了一个失败的响应:
发现报空指针异常:
那么password为什么为空呢?
我们回到SystemUser类中,可以看到…
select=false
意味着当执行查询操作时,该字段(在这个例子中是password)将不会被包含在查询结果中,也就是没有给前端反馈password,systemUserMapper.selectOne()查询到的password就为null。
找到原因之后,最通用的做法就是不使用通用的select语句,自定义一个mapper:
编写Mapper层逻辑
- 在
LoginMapper
中增加如下内容
SystemUser selectOneByUsername(String username);
在LoginMapper.xml
中增加如下内容
<select id="selectOneByUsername" resultType="com.atguigu.lease.model.entity.SystemUser">
select id,
username,
password,
name,
type,
phone,
avatar_url,
additional_info,
post_id,
status
from system_user
where is_deleted = 0
and username = #{username}
</select>
编写HandlerInterceptor
我们需要为所有受保护(指登录之后才能访问)的接口增加校验JWT合法性的逻辑。我们可以用一个拦截器来实现,具体实现如下
- 在
JwtUtil
中增加parseToken
方法,检验其合法性,内容如下
// 令牌过期时间为1小时
private static long tokenExpiration = 60 * 60 * 1000L;
private static SecretKey tokenSignKey = Keys.hmacShaKeyFor("M0PKKI6pYGVWWfDZw90a0lTpGYX1d4AQ".getBytes());
public static void parseToken(String token){
try{
JwtParser jwtParser = Jwts.parserBuilder().setSigningKey(tokenSignKey ).build();
jwtParser.parseClaimsJws(token);
}catch (ExpiredJwtException e){
throw new LeaseException(ResultCodeEnum.TOKEN_EXPIRED);
}catch (JwtException e){
throw new LeaseException(ResultCodeEnum.TOKEN_INVALID);
}
}
编写HandlerInterceptor
在web-admin模块中创建com.atguigu.lease.web.admin.custom.interceptor.AuthenticationInterceptor
类,内容如下,有关HanderInterceptor
的相关内容,可参考官方文档。
@Component
public class AuthenticationInterceptor implements HandlerInterceptor {
@Override
public boolean preHandle(HttpServletRequest request, HttpServletResponse response, Object handler) throws Exception {
String token = request.getHeader("access-token");
JwtUtil.parseToken(token);
return true;
}
}
注意:我们约定,前端登录后,后续请求都将JWT,放置于HTTP请求的Header中,其Header的key为access-token
。
注册HandlerInterceptor
在web-admin模块的com.atguigu.lease.web.admin.custom.config.WebMvcConfiguration
中增加如下内容
@Autowired
private AuthenticationInterceptor authenticationInterceptor;
@Override
public void addInterceptors(InterceptorRegistry registry) {
registry.addInterceptor(this.authenticationInterceptor).addPathPatterns("/admin/**").excludePathPatterns("/admin/login/**");
}
3. 获取登录用户个人信息
-
查看请求和响应的数据结构
-
响应的数据结构
查看web-admin模块下的
com.atguigu.lease.web.admin.vo.system.user.SystemUserInfoVo
,内容如下
-
@Schema(description = "员工基本信息")
@Data
public class SystemUserInfoVo {
@Schema(description = "用户姓名")
private String name;
@Schema(description = "用户头像")
private String avatarUrl;
}
请求的数据结构
按理说,前端若想获取当前登录用户的个人信息,需要传递当前用户的id
到后端进行查询。但是由于请求中携带的JWT中就包含了当前登录用户的id
,故请求个人信息时,就无需再传递id
。
修改JwtUtil
中的parseToken
方法
由于需要从Jwt中获取用户id
,因此需要为parseToken
方法增加返回值,如下
public static Claims parseToken(String token){
if (token==null){
throw new LeaseException(ResultCodeEnum.ADMIN_LOGIN_AUTH);
}
try{
JwtParser jwtParser = Jwts.parserBuilder().setSigningKey(secretKey).build();
return jwtParser.parseClaimsJws(token).getBody();
}catch (ExpiredJwtException e){
throw new LeaseException(ResultCodeEnum.TOKEN_EXPIRED);
}catch (JwtException e){
throw new LeaseException(ResultCodeEnum.TOKEN_INVALID);
}
}
编写ThreadLocal工具类
理论上我们可以在Controller方法中,使用@RequestHeader
获取JWT,然后在进行解析,如下
@Operation(summary = "获取登陆用户个人信息")
@GetMapping("info")
public Result<SystemUserInfoVo> info(@RequestHeader("access-token") String token) {
Claims claims = JwtUtil.parseToken(token);
Long userId = claims.get("userId", Long.class);
SystemUserInfoVo userInfo = service.getLoginUserInfo(userId);
return Result.ok(userInfo);
}
上述代码的逻辑没有任何问题,但是这样做,JWT会被重复解析两次(一次在拦截器中,一次在该方法中)。为避免重复解析,通常会在拦截器将Token解析完毕后,将结果保存至ThreadLocal中,这样一来,我们便可以在整个请求的处理流程中进行访问了。
在common模块中创建com.atguigu.lease.common.login.LoginUserHolder
工具类
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();
}
}
同时在common模块中创建com.atguigu.lease.common.login.LoginUser
类
@Data
@AllArgsConstructor
public class LoginUser {
private Long userId;
private String username;
}
修改AuthenticationInterceptor
拦截器
@Component
public class AuthenticationInterceptor implements HandlerInterceptor {
@Override
public boolean preHandle(HttpServletRequest request, HttpServletResponse response, Object handler) throws Exception {
String token = request.getHeader("access-token");
Claims claims = JwtUtil.parseToken(token);
Long userId = claims.get("userId", Long.class);
String username = claims.get("username", String.class);
// 把解析出来的userid和username放到ThreadLocal中
LoginUserHolder.setLoginUser(new LoginUser(userId, username));
return true;
}
// 拦截器执行完之后执行, 清理线程变量
@Override
public void afterCompletion(HttpServletRequest request, HttpServletResponse response, Object handler, Exception ex) throws Exception {
LoginUserHolder.clear();
}
}
编写Controller层逻辑
在LoginController
中增加如下内容
@Operation(summary = "获取登陆用户个人信息")
@GetMapping("info")
public Result<SystemUserInfoVo> info() {
SystemUserInfoVo userInfo = loginService.getLoginUserInfo(LoginUserHolder.getLoginUser().getUserId());
return Result.ok(userInfo);
}
编写Service层逻辑
在LoginService
中增加如下内容
@Override
public SystemUserInfoVo getLoginUserInfo(Long userId) {
SystemUser systemUser = systemUserMapper.selectById(userId);
SystemUserInfoVo systemUserInfoVo = new SystemUserInfoVo();
systemUserInfoVo.setName(systemUser.getName());
systemUserInfoVo.setAvatarUrl(systemUser.getAvatarUrl());
return systemUserInfoVo;
}