一、背景知识
1. 认证方案概述
有两种常见的认证方案,分别是基于Session的认证和基于Token的认证,下面逐一进行介绍
-
基于Session
基于Session的认证流程如下图所示
-
该方案的特点
- 登录用户信息保存在服务端内存中,若访问量增加,单台节点压力会较大
- 随用户规模增大,若后台升级为集群,则需要解决集群中各服务器登录状态共享的问题。
-
基于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中指定的签名算法)计算得到的一个字符串,用于防止消息被篡改。
二、登录流程
后台管理系统的登录流程如下图所示
根据上述登录流程,可分析出,登录管理共需三个接口,分别是获取图形验证码、登录、获取登录用户个人信息,除此之外,我们还需为所有受保护的接口增加验证JWT合法性的逻辑,这一功能可通过HandlerInterceptor
来实现。
三、接口开发
首先在LoginController
中注入LoginService
,如下
@Tag(name = "后台管理系统登录管理")
@RestController
@RequestMapping("/admin")
public class LoginController {
@Autowired
private LoginService service;
}
1. 获取图形验证码
-
查看响应的数据结构
查看web-admin模块下的
com.echo.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> <version>1.6.2</version> </dependency>
-
Redis
在common模块的pom.xml中增加如下内容
<dependency> <groupId>org.springframework.boot</groupId> <artifactId>spring-boot-starter-data-redis</artifactId> <version>3.0.5</version> </dependency>
在
application.yml
中增加如下配置spring: data: redis: host: <hostname> port: <port> database: 0
注意:上述
hostname
和port
需根据实际情况进行修改
-
-
编写Controller层逻辑
在
LoginController
中增加如下内容@Operation(summary = "获取图形验证码") @GetMapping("login/captcha") public Result<CaptchaVo> getCaptcha() { CaptchaVo captcha = service.getCaptcha(); return Result.ok(captcha); }
-
编写Service层逻辑
-
在
LoginService
中增加如下内容CaptchaVo getCaptcha();
-
在
LoginServiceImpl
中增加如下内容@Autowired private StringRedisTemplate redisTemplate; @Override public CaptchaVo getCaptcha() { SpecCaptcha specCaptcha = new SpecCaptcha(130, 48, 4); specCaptcha.setCharType(Captcha.TYPE_DEFAULT); String code = specCaptcha.text().toLowerCase(); String key = RedisConstant.ADMIN_LOGIN_PREFIX + UUID.randomUUID(); String image = specCaptcha.toBase64(); redisTemplate.opsForValue().set(key, code, RedisConstant.ADMIN_LOGIN_CAPTCHA_TTL_SEC, TimeUnit.SECONDS); return new CaptchaVo(image, key); }
知识点:
-
本项目Reids中的key需遵循以下命名规范:项目名:功能模块名:其他,例如
admin:login:123456
-
spring-boot-starter-data-redis
已经完成了StringRedisTemplate
的自动配置,我们直接注入即可。 -
为方便管理,可以将Reids相关的一些值定义为常量,例如key的前缀、TTL时长,内容如下。大家可将这些常量统一定义在common模块下的
com.echo.lease.common.constant.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.echo.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> <version>0.12.6</version> </dependency> <dependency> <groupId>io.jsonwebtoken</groupId> <artifactId>jjwt-impl</artifactId> <scope>runtime</scope> <version>0.12.6</version> </dependency> <dependency> <groupId>io.jsonwebtoken</groupId> <artifactId>jjwt-jackson</artifactId> <scope>runtime</scope> <version>0.12.6</version> </dependency>
-
创建JWT工具类
在common模块下创建
com.echo.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) { return Jwts.builder(). subject("USER_INFO"). expiration(new Date(System.currentTimeMillis() + tokenExpiration)). claim("userId", userId). claim("username", username). signWith(tokenSignKey). compact(); } }
-
-
编写Controller层逻辑
在
LoginController
中增加如下内容@Operation(summary = "登录") @PostMapping("login") public Result<String> login(@RequestBody LoginVo loginVo) { String token = service.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.校验用户是否存在 SystemUser systemUser = systemUserMapper.selectOneByUsername(loginVo.getUsername()); 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()); }
-
-
编写Mapper层逻辑
-
在
LoginMapper
中增加如下内容SystemUser selectOneByUsername(String username);
-
在
LoginMapper.xml
中增加如下内容<select id="selectOneByUsername" resultType="com.echo.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
方法,内容如下public static Claims parseToken(String token) { if (token == null) { throw new LeaseException(ResultCodeEnum.ADMIN_LOGIN_AUTH); } try { return Jwts.parser() .verifyWith(tokenSignKey) .build() .parseSignedClaims(token) .getPayload(); } catch (ExpiredJwtException e) { throw new LeaseException(ResultCodeEnum.TOKEN_EXPIRED); } catch (JwtException e) { throw new LeaseException(ResultCodeEnum.TOKEN_INVALID); } }
-
编写HandlerInterceptor
在web-admin模块中创建
com.echo.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 { // Header 中获取 token String token = request.getHeader("Authorization"); token = StrUtil.subAfter(token, "Bearer ", true); Claims claims = JwtUtil.parseToken(token); return true; } }
注意:
我们约定,前端登录后,后续请求都将JWT,放置于HTTP请求的Header中,其Header的key为
access-token
。 -
注册HandlerInterceptor
在web-admin模块的
com.echo.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/**"); }
-
-
Knife4j配置
在增加上述拦截器后,为方便继续调试其他接口,可以获取一个长期有效的Token,将其配置到Knife4j的全局参数中,如下图所示。
注意:每个接口分组需要单独配置
刷新页面,任选一个接口进行调试,会发现发送请求时会自动携带该header,如下图所示
-
3. 获取登录用户个人信息
-
查看请求和响应的数据结构
-
响应的数据结构
查看web-admin模块下的
com.echo.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中,这样一来,我们便可以在整个请求的处理流程中进行访问了。
ThreadLocal概述
ThreadLocal的主要作用是为每个使用它的线程提供一个独立的变量副本,使每个线程都可以操作自己的变量,而不会互相干扰,其用法如下图所示。
在common模块中创建
com.echo.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.echo.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 { // Header 中获取 token String token = request.getHeader("Authorization"); token = StrUtil.subAfter(token, "Bearer ", true); Claims claims = JwtUtil.parseToken(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(); } }
-
编写Controller层逻辑
在
LoginController
中增加如下内容@Operation(summary = "获取登陆用户个人信息") @GetMapping("info") public Result<SystemUserInfoVo> info() { SystemUserInfoVo userInfo = service.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; }
笔记整理自尚硅谷Java项目【尚庭公寓】从0开始Java项目实战