登录的各种写法
总的来说,是用token,以下文章也是记录token的写法,不写cookie的写法
简单写法
整体的流程
主要的核心思想就是前端传过来用户名 + 密码 我们生成token返回去
当我们接收其他除了登录的接口的时候,加上这个token,我们再做一个拦截的代码验证,通过就给过,不通过,返回错误
这里我们,如果其他地方需要这里的用户信息的化,一般我们用TreadLocal来存储当前用户id,如下编写一个工具类
package com.sky.context;
public class BaseContext {
public static ThreadLocal<Long> threadLocal = new ThreadLocal<>();
public static void setCurrentId(Long id) {
threadLocal.set(id);
}
public static Long getCurrentId() {
return threadLocal.get();
}
public static void removeCurrentId() {
threadLocal.remove();
}
}
用户登录
/**
* 登录
*
* @param employeeLoginDTO
* @return
*/
@PostMapping("/login")
@ApiOperation("员工登录")
public Result<EmployeeLoginVO> login(@RequestBody EmployeeLoginDTO employeeLoginDTO) {
log.info("员工登录:{}", employeeLoginDTO);
//登录
Employee employee = employeeService.login(employeeLoginDTO);
//登录成功后,生成jwt令牌
Map<String, Object> claims = new HashMap<>();
claims.put(JwtClaimsConstant.EMP_ID, employee.getId());
String token = JwtUtil.createJWT(
jwtProperties.getAdminSecretKey(),
jwtProperties.getAdminTtl(),
claims);
EmployeeLoginVO employeeLoginVO = EmployeeLoginVO.builder()
.id(employee.getId())
.userName(employee.getUsername())
.name(employee.getName())
.token(token)
.build();
return Result.success(employeeLoginVO);
}
这里的login方法,没有什么代码逻辑,也就是校验一下密码,或者我们可以写自己的自定义的校验,例如,有可能用户是被禁用的,就返回错误信息
这里的jwtutil 工具类,如下
package com.sky.utils;
import io.jsonwebtoken.Claims;
import io.jsonwebtoken.JwtBuilder;
import io.jsonwebtoken.Jwts;
import io.jsonwebtoken.SignatureAlgorithm;
import java.nio.charset.StandardCharsets;
import java.util.Date;
import java.util.Map;
public class JwtUtil {
/**
* 生成jwt
* 使用Hs256算法, 私匙使用固定秘钥
*
* @param secretKey jwt秘钥
* @param ttlMillis jwt过期时间(毫秒)
* @param claims 设置的信息
* @return
*/
public static String createJWT(String secretKey, long ttlMillis, Map<String, Object> claims) {
// 指定签名的时候使用的签名算法,也就是header那部分
SignatureAlgorithm signatureAlgorithm = SignatureAlgorithm.HS256;
// 生成JWT的时间
long expMillis = System.currentTimeMillis() + ttlMillis;
Date exp = new Date(expMillis);
// 设置jwt的body
JwtBuilder builder = Jwts.builder()
// 如果有私有声明,一定要先设置这个自己创建的私有的声明,这个是给builder的claim赋值,一旦写在标准的声明赋值之后,就是覆盖了那些标准的声明的
.setClaims(claims)
// 设置签名使用的签名算法和签名使用的秘钥
.signWith(signatureAlgorithm, secretKey.getBytes(StandardCharsets.UTF_8))
// 设置过期时间
.setExpiration(exp);
return builder.compact();
}
/**
* Token解密
*
* @param secretKey jwt秘钥 此秘钥一定要保留好在服务端, 不能暴露出去, 否则sign就可以被伪造, 如果对接多个客户端建议改造成多个
* @param token 加密后的token
* @return
*/
public static Claims parseJWT(String secretKey, String token) {
// 得到DefaultJwtParser
Claims claims = Jwts.parser()
// 设置签名的秘钥
.setSigningKey(secretKey.getBytes(StandardCharsets.UTF_8))
// 设置需要解析的jwt
.parseClaimsJws(token).getBody();
return claims;
}
}
过滤器过滤
咱们拦截器需要如下设置
package com.sky.interceptor;
import com.sky.constant.JwtClaimsConstant;
import com.sky.context.BaseContext;
import com.sky.properties.JwtProperties;
import com.sky.utils.JwtUtil;
import io.jsonwebtoken.Claims;
import lombok.extern.slf4j.Slf4j;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.stereotype.Component;
import org.springframework.web.method.HandlerMethod;
import org.springframework.web.servlet.HandlerInterceptor;
import javax.servlet.http.HttpServletRequest;
import javax.servlet.http.HttpServletResponse;
/**
* jwt令牌校验的拦截器
*/
@Component
@Slf4j
public class JwtTokenAdminInterceptor implements HandlerInterceptor {
@Autowired
private JwtProperties jwtProperties;
/**
* 校验jwt
*
* @param request
* @param response
* @param handler
* @return
* @throws Exception
*/
public boolean preHandle(HttpServletRequest request, HttpServletResponse response, Object handler) throws Exception {
//判断当前拦截到的是Controller的方法还是其他资源
if (!(handler instanceof HandlerMethod)) {
//当前拦截到的不是动态方法,直接放行
return true;
}
//1、从请求头中获取令牌
String token = request.getHeader(jwtProperties.getAdminTokenName());
//2、校验令牌
try {
log.info("jwt校验:{}", token);
Claims claims = JwtUtil.parseJWT(jwtProperties.getAdminSecretKey(), token);
Long empId = Long.valueOf(claims.get(JwtClaimsConstant.EMP_ID).toString());
log.info("当前员工id:", empId);
//设置当前用户id,到ThreadLocal中
BaseContext.setCurrentId(empId);
//3、通过,放行
return true;
} catch (Exception ex) {
//4、不通过,响应401状态码
response.setStatus(401);
return false;
}
}
}
这里,先是把从请求头中获取到令牌,先,然后就是校验一下令牌,使用我们自己写的parseJWT,然后把empId写到ThreadLocal里边,之后我们要是用到这个id的化,可以直接从ThreadLocal中直接拿
如果校验不通过的化,就返回401,return false
写入配置类
/**
* 配置类,注册web层相关组件
*/
@Configuration
@Slf4j
public class WebMvcConfiguration extends WebMvcConfigurationSupport {
@Autowired
private JwtTokenAdminInterceptor jwtTokenAdminInterceptor;
/**
* 注册自定义拦截器
*
* @param registry
*/
protected void addInterceptors(InterceptorRegistry registry) {
log.info("开始注册自定义拦截器...");
registry.addInterceptor(jwtTokenAdminInterceptor)
.addPathPatterns("/admin/**")
.excludePathPatterns("/admin/employee/login");
}
}
这里的addPathPatterns就是加路径映射
excludePathPatterns就是排除,名字显而易见
最后我们再其他地方就可以直接拿到这里的userId
顺便这里整合mybatis-plus的化,我们可以再自动注入里边加入
如下设置
/**
* @author jjking
* @date 2024-01-22 20:23
*/
@Component
public class MyMetaObjectHandler implements MetaObjectHandler {
@Override
public void insertFill(MetaObject metaObject) {
//自动把下面四个字段新增了值。
this.setFieldValByName("createTime", LocalDateTime.now(), metaObject);
this.setFieldValByName("updateTime", LocalDateTime.now(), metaObject);
this.setFieldValByName("createUser", BaseContext.getCurrentId(), metaObject);
this.setFieldValByName("updateUser", BaseContext.getCurrentId(), metaObject);
}
@Override
public void updateFill(MetaObject metaObject) {
this.setFieldValByName("updateTime", LocalDateTime.now(), metaObject);
this.setFieldValByName("createUser", BaseContext.getCurrentId(), metaObject);
}
}
要在entity中特别写tablefield fill
//@JsonFormat(pattern = "yyyy-MM-dd HH:mm:ss")
@TableField(fill = FieldFill.INSERT)
private LocalDateTime createTime;
//@JsonFormat(pattern = "yyyy-MM-dd HH:mm:ss")
@TableField(fill = FieldFill.INSERT_UPDATE)
private LocalDateTime updateTime;
@TableField(fill = FieldFill.INSERT)
private Long createUser;
@TableField(fill = FieldFill.INSERT_UPDATE)
private Long updateUser;
jwtProperties
这里的jwtProperties就是我们写在yml里边的,相当于一个值的映射
我们可以自定义配置属性
@Component
@ConfigurationProperties(prefix = "sky.jwt")
@Data
public class JwtProperties {
/**
* 管理端员工生成jwt令牌相关配置
*/
private String adminSecretKey;
private long adminTtl;
private String adminTokenName;
/**
* 用户端微信用户生成jwt令牌相关配置
*/
private String userSecretKey;
private long userTtl;
private String userTokenName;
}
sky:
jwt:
# 设置jwt签名加密时使用的秘钥
admin-secret-key: itcast
# 设置jwt过期时间
admin-ttl: 7200000
# 设置前端传递过来的令牌名称
admin-token-name: token
过滤器过滤小bug
我写这里的过滤器的时候,发现这里有一个小bug
public boolean preHandle(HttpServletRequest request, HttpServletResponse response, Object handler) throws Exception {
//判断当前拦截到的是Controller的方法还是其他资源
boolean flag = handler instanceof HandlerMethod;
if (!(handler instanceof HandlerMethod)) {
//当前拦截到的不是动态方法,直接放行
return true;
}
}
解释业务逻辑
这里的HandlerMethod就是controller类的方法,算是controller类的代表,如果这里不是controller的方法的化就会认为是其他的什么静态资源过滤什么的
我在写用户端的时候,发现这个过滤器,竟然不过滤请求了
这里所有的请求直接到了
这里的return true
我debug的时候,也发现这里的handler是HandlerMethod啊,怎么会都进去了呢?
然后我发现,问题出在我导包导错了,这里的导包是我导成如下了
import org.springframework.messaging.handler.HandlerMethod;
实际正确导包是
import org.springframework.web.method.HandlerMethod;
到完之后,就对了
所以,以后遇到这种debug发现也是对的,有可能就是导包问题!
整合SpringSecurity登录
来看我这一part的时候,需要先去了解SprintSecurity,再次只是做整合,对于SpringSecurity的博客我会再写。
首先我们再实现登录功能之前,我们就得搞清楚我们的目标是什么?
第一,我们要登录
第二,除了登录接口,普通的请求,需要校验是否登录过
由这两个目标,我们再做详细的介绍,简单来说,一个是登录,一个是校验
准备工作
首先我们得导入SpringSecurity
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-security</artifactId>
</dependency>
SpirngSecurity配置类
@Configuration
public class SecurityConfig extends WebSecurityConfigurerAdapter {
@Resource
private JwtAuthenticationTokenFilter jwtAuthenticationTokenFilter;
@Resource
private AuthenticationEntryPoint authenticationEntryPoint;
@Resource
private AccessDeniedHandler accessDeniedHandler;
@Bean
@Override
public AuthenticationManager authenticationManagerBean() throws Exception {
return super.authenticationManagerBean();
}
@Bean
public BCryptPasswordEncoder passwordEncoder() {
return new BCryptPasswordEncoder();
}
/**
* 全局配置
*
* @param http
* @throws Exception
*/
@Override
protected void configure(HttpSecurity http) throws Exception {
http
//关闭csrf
.csrf().disable()
//不通过Session获取SecurityContext
.sessionManagement().sessionCreationPolicy(SessionCreationPolicy.STATELESS)
.and()
.authorizeRequests()
// 对于登录接口 允许匿名访问
.antMatchers("/user/login").anonymous()
// //需要认证才能访问
.antMatchers("/user/logout").authenticated()
// .antMatchers("/user/userInfo").authenticated()
// .antMatchers("/upload").authenticated()
// 除上面外的所有请求都不需要认证
.anyRequest().permitAll();
//配置异常处理器
http.exceptionHandling()
.authenticationEntryPoint(authenticationEntryPoint)
.accessDeniedHandler(accessDeniedHandler);
//关闭默认的注销功能
http.logout().disable();
http.addFilterBefore(jwtAuthenticationTokenFilter, UsernamePasswordAuthenticationFilter.class);
//允许跨域
http.cors();
}
}
这些配置都是看自己的,
但是有几个必须配置
JWT工具类
/**
* JWT工具类
*/
public class JwtUtils {
//有效期为
public static final Long JWT_TTL = 60 * 60 *1000L;// 60 * 60 *1000 一个小时
//设置秘钥明文
public static final String JWT_KEY = "sangeng";
public static String getUUID(){
String token = UUID.randomUUID().toString().replaceAll("-", "");
return token;
}
/**
* 生成jtw
* @param subject token中要存放的数据(json格式)
* @return
*/
public static String createJWT(String subject) {
JwtBuilder builder = getJwtBuilder(subject, null, getUUID());// 设置过期时间
return builder.compact();
}
/**
* 生成jtw
* @param subject token中要存放的数据(json格式)
* @param ttlMillis token超时时间
* @return
*/
public static String createJWT(String subject, Long ttlMillis) {
JwtBuilder builder = getJwtBuilder(subject, ttlMillis, getUUID());// 设置过期时间
return builder.compact();
}
private static JwtBuilder getJwtBuilder(String subject, Long ttlMillis, String uuid) {
SignatureAlgorithm signatureAlgorithm = SignatureAlgorithm.HS256;
SecretKey secretKey = generalKey();
long nowMillis = System.currentTimeMillis();
Date now = new Date(nowMillis);
if(ttlMillis==null){
ttlMillis=JwtUtils.JWT_TTL;
}
long expMillis = nowMillis + ttlMillis;
Date expDate = new Date(expMillis);
return Jwts.builder()
.setId(uuid) //唯一的ID
.setSubject(subject) // 主题 可以是JSON数据
.setIssuer("sg") // 签发者
.setIssuedAt(now) // 签发时间
.signWith(signatureAlgorithm, secretKey) //使用HS256对称加密算法签名, 第二个参数为秘钥
.setExpiration(expDate);
}
/**
* 创建token
* @param id
* @param subject
* @param ttlMillis
* @return
*/
public static String createJWT(String id, String subject, Long ttlMillis) {
JwtBuilder builder = getJwtBuilder(subject, ttlMillis, id);// 设置过期时间
return builder.compact();
}
/**
* 生成加密后的秘钥 secretKey
* @return
*/
public static SecretKey generalKey() {
byte[] encodedKey = Base64.getDecoder().decode(JwtUtils.JWT_KEY);
SecretKey key = new SecretKeySpec(encodedKey, 0, encodedKey.length, "AES");
return key;
}
/**
* 解析
*
* @param jwt
* @return
* @throws Exception
*/
public static Claims parseJWT(String jwt) throws Exception {
SecretKey secretKey = generalKey();
return Jwts.parser()
.setSigningKey(secretKey)
.parseClaimsJws(jwt)
.getBody();
}
}
登录
首先,前端发送请求,到后端来,请求体是用户名 + 密码
我们在数据库中,校验了用户名 + 密码之后,如果通过的化,我们就要生成jwt,返回给前端,也就是我们所谓的token
好了,目标明确,校验传来的信息 + 生成token
请看流程图
代码
Controller
@PostMapping("/user/login")
public ResponseResult login(@RequestBody User user) {
return loginService.login(user);
}
实现类
@Service
public class LoginServiceImpl implements LoginService {
@Resource
private AuthenticationManager authenticationManager;
@Resource
private RedisCache redisCache;
@Resource
private MenuService menuService;
@Resource
private RoleService roleService;
@Override
public ResponseResult login(User user) {
// 1.根据username和password封装Authentication对象
UsernamePasswordAuthenticationToken authenticationToken =
new UsernamePasswordAuthenticationToken(user.getUserName(), user.getPassword());
// 2.AuthenticationManager调用authenticate方法,再调用UserDetailsService的loadUserByUsername方法
Authentication authenticate = authenticationManager.authenticate(authenticationToken);
// 3.判断认证是否通过
if (Objects.isNull(authenticate)) {
throw new RuntimeException("用户名或密码错误!");
}
// 4.获取到用户id
LoginUser loginUser = (LoginUser) authenticate.getPrincipal();
String userId = loginUser.getUser().getId().toString();
String jwt = JwtUtils.createJWT(userId);
// 5.将用户信息存入缓存
redisCache.setCacheObject(BLOG_USER_LOGIN + userId, loginUser);
//6.封装token返回
HashMap<String, String> map = new HashMap<>();
map.put("token", jwt);
return ResponseResult.okResult(map);
}
}
这样的代码虽然多,但是其实不是很难
我们先从容易的下手,对于第5步,和第6步来说,
第5步,就是将用户信息存入redis
第6步,就是将生成的token封装回去,这两步很多时候是根据我们自己的业务来看的,并不重要。
比较重要的是前两步
第一,我们要将发送过来的用户名 + 密码,封装到UsernamePasswordAuthenticationToken,这个对象中去,这个对象是什么意思你不需要深入了解,我们的目的是封装Authentication对象,并且调用authenticate方法。调用authenticate方法就是为了去校验数据库中的信息和发送过来的信息是不是一样的
简单的来说,authenticate方法,就是去查数据库,账户密码是否正确。
由此,我们要考虑一个问题,诶,我们使用Springsecurity,没有写校验的方法啊?所以,为了配合这个框架,我们要去实现UserDetailService的loadUserByUsername方法,这个方法实实在在就是去校验的方法,也就是我们要自己写的校验逻辑
@Service
public class UserDetailsServiceImpl implements UserDetailsService {
@Resource
private UserMapper userMapper;
@Resource
private UserService userService;
@Resource
private MenuMapper menuMapper;
@Override
public UserDetails loadUserByUsername(String username) throws UsernameNotFoundException {
//1.根据用户名,查询用户信息
LambdaQueryWrapper<User> wrapper = new LambdaQueryWrapper<>();
wrapper.eq(User::getUserName,username);
User user = userMapper.selectOne(wrapper);
//2.判断是否查到用户,没有抛出异常
Optional.ofNullable(user).orElseThrow(RuntimeException::new);
//3.如果是后台用户,添加权限
if (SystemConstants.ADMIN.equals(user.getType())){
List<String> list = menuMapper.selectPermsByUserId(user.getId());
return new LoginUser(user,list);
}
return new LoginUser(user,null);
}
}
我们看SpringSecuriy authenticate方法,就是会调用这个方法,返回的对象是UserDetails,我这里的LoginUser就是实现了UserDetails,我这里贴一下LoginUser的代码
@Data
@NoArgsConstructor
@AllArgsConstructor
public class LoginUser implements UserDetails {
private User user;
private List<String> permissions;
@Override
public Collection<? extends GrantedAuthority> getAuthorities() {
return null;
}
@Override
public String getPassword() {
return user.getPassword();
}
@Override
public String getUsername() {
return user.getUserName();
}
@Override
public boolean isAccountNonExpired() {
return true;
}
@Override
public boolean isAccountNonLocked() {
return true;
}
@Override
public boolean isCredentialsNonExpired() {
return true;
}
@Override
public boolean isEnabled() {
return true;
}
}
这个方法的实现,不管你怎么实现,都可以,只要最后返回的是UserDetails对象就ok了,我这里的代码是我的业务代码,去数据库中查,然后,返回查到的结果,封装到LoginUser里边
这里我不得不插一嘴,我们用这些框架,首先是把这些框架用熟了,而不是一上来就研究源码,我认为,这是十分愚蠢的,我之前也是这样,我们只要先跑起来,通过实验反推理论,再去研究理论,才是成神之道,需要干什么,需要返回什么,需要怎么写,我们先实现了就行,不用太在意细枝末节,我之前就是一个在意细枝末节的人,没必要,我现在认为,对于写代码,跑起来,才是最重要的。
至此,我们的登录,就完成
校验
为了,还是同一做测试,我们先把校验给完成了。
直接先看流程图
代码
@Component
public class JwtAuthenticationTokenFilter extends OncePerRequestFilter {
@Resource
private RedisCache redisCache;
@Override
protected void doFilterInternal(HttpServletRequest request, HttpServletResponse response, FilterChain filterChain) throws ServletException, IOException {
//获取请求头的token
String token = request.getHeader("token");
if (!StringUtils.hasText(token)){
filterChain.doFilter(request,response);
return;
}
//解析获取userId
Claims claims = null;
try {
claims = JwtUtils.parseJWT(token);
} catch (Exception e) {
e.printStackTrace();
//token超时,token非法
//响应告诉前端
ResponseResult result = ResponseResult.errorResult(AppHttpCodeEnum.NEED_LOGIN);
WebUtils.renderString(response, JSON.toJSONString(result));
return;
}
//解析通过,获得userId
String userId = claims.getSubject();
//从Redis中获取用户信息
LoginUser loginUser = redisCache.getCacheObject(RedisConstants.BLOG_USER_LOGIN + userId);
//获取不到
if (Objects.isNull(loginUser)){
//说明登录过期
ResponseResult result = ResponseResult.errorResult(AppHttpCodeEnum.NEED_LOGIN);
WebUtils.renderString(response, JSON.toJSONString(result));
return;
}
//存入SecurityContextHolder
UsernamePasswordAuthenticationToken token1 = new UsernamePasswordAuthenticationToken(loginUser,null,null);
SecurityContextHolder.getContext().setAuthentication(token1);
filterChain.doFilter(request,response);
}
}
我们来看整体的过程
- 获取请求头中的token,token不存在,拒绝访问。
- 解析token,生成用户id,生成失败,或者token非法,超时,拒绝访问。
- 从redis中,查询第二步生成的用户id,如果查询为空,则说明登录过期,拒绝访问,并返回需要登陆的状态码。
- 上述步骤都通过,将用户信息存入SecurityContextHolder,类似ThreadLocal,以便我们方便的获取用户信息。
测试
测试接口
localhost:8989/user/login
请求体
{
"userName" :"sg",
"password" : "1234"
}
返回结果
返回就是token,测试正确