前后端分离项目SpringBoot+SpringSecurity 图片验证码登录功能的实现(详细)

在实现图片验证码登录功能之前,我们需要了解一些springsecurity与前后端分离项目基础登录流程知识,然后实现不带验证码的登录功能,如果你只想看图片验证码功能的实现,可以直接翻到后面查看。下面我先依次介绍这两个知识点(前置知识,后面会用到),实现前后端分离项目不带验证码的基础登录功能

1.前置知识点介绍(不带验证码校验的基础登录功能的实现)

前后端分离项目基础登录流程

前后端分离项目中无法使用session,所以使用jwt生成token令牌作为客户端进行请求的一个标识,当用户第一次登录后,服务器生成一个token并将此token返回给客户端,以后客户端只需带上这个token前来请求数据即可,无需再次带上用户名和密码。

前后端分离项目SpringBoot+SpringSecurity 图片验证码登录功能的实现(详细)_第1张图片

springsecurity + 基础不带验证码登录功能的实现
 

一.springsecurity简介

1.认证 (你是谁)
2.授权 (你能干什么)
3.攻击防护 (防止伪造身份)
其核心就是一组过滤器链,内部包含了提供各种功能的过滤器,项目启动后将会自动配置。登录认证使用到的核心过滤器就是UsernamePasswordAuthenticationFilter

下面我们来看看基础的登录功能中所需要的过滤器。

前后端分离项目SpringBoot+SpringSecurity 图片验证码登录功能的实现(详细)_第2张图片

图中只展示了核心过滤器,其它的非核心过滤器并没有在图中展示。

UsernamePasswordAuthenticationFilter:负责处理我们在登陆页面填写了用户名密码后的登陆请求。入门案例的认证工作主要有它负责。

ExceptionTranslationFilter:处理过滤器链中抛出的任何AccessDeniedException和AuthenticationException 。

FilterSecurityInterceptor:负责权限校验的过滤器。

我们可以通过Debug查看当前系统中SpringSecurity过滤器链中有哪些过滤器及它们的顺序。

前后端分离项目SpringBoot+SpringSecurity 图片验证码登录功能的实现(详细)_第3张图片

如果想要了解各个过滤器的详细作用,可以看看_江南一点雨博主发的这篇博客文章,里面介绍springsecurity中的过滤器链中各个过滤器的功能作用,链接如下:

Spring Security 工作原理概览__江南一点雨的博客-CSDN博客

二.基础登录认证流程详解

前后端分离项目SpringBoot+SpringSecurity 图片验证码登录功能的实现(详细)_第4张图片

概念速查:

Authentication接口: 它的实现类,表示当前访问系统的用户,封装了用户相关信息。

AuthenticationManager接口:定义了认证Authentication的方法

UserDetailsService接口:加载用户特定数据的核心接口。里面定义了一个根据用户名查询用户信息的方法 UserDetails loadUserByUsername(String var1),实现UserDetailsService接口,在实现类中重写loadUserByUsername方法,我们可以在这个方法里做验证码的校验,登录用户名的校验与登录密码的校验。

UserDetails接口:提供核心用户信息。通过UserDetailsService根据用户名获取处理的用户信息要封装成UserDetails对象返回。然后将这些信息封装到Authentication对象中。我们需要做的就是自定义一个UserDetails接口的实现类,重写UserDetails接口实现类中的方法,添加必要字段(如用户对象字段,用户操作权限数组字段等等)

.基础不带验证码校验登录认证功能的实现

1.思路分析

登录

①自定义登录接口

调用ProviderManager的方法进行认证 如果认证通过生成jwt

把用户信息存入redis中

②自定义UserDetailsService

在这个实现类中去查询数据库

校验:

①定义Jwt认证过滤器

获取token

解析token获取其中的userid

从redis中获取用户信息

存入SecurityContextHolder

2.创建springboot工程

在idea中创建springboot项目,创建springboot项目就不过多介绍了,不会的可以参考小白逆流而上博主的搭建springboot项目相关教程文章,链接如下:

使用IDEA搭建一个简单的SpringBoot项目——详细过程_小白逆流而上的博客-CSDN博客_springboot项目

3.准备工作

①添加依赖


       
        
            org.springframework.boot
            spring-boot-starter-data-redis
        
        
        
            com.alibaba
            fastjson
            1.2.33
        
        
        
            io.jsonwebtoken
            jjwt
            0.9.0
        
        
            org.springframework.boot
            spring-boot-starter-security
        
        
            org.projectlombok
            lombok
            true
        

② 添加Redis相关配置

import com.alibaba.fastjson.JSON;
import com.alibaba.fastjson.serializer.SerializerFeature;
import com.fasterxml.jackson.databind.JavaType;
import com.fasterxml.jackson.databind.ObjectMapper;
import com.fasterxml.jackson.databind.type.TypeFactory;
import org.springframework.data.redis.serializer.RedisSerializer;
import org.springframework.data.redis.serializer.SerializationException;
import com.alibaba.fastjson.parser.ParserConfig;
import org.springframework.util.Assert;
import java.nio.charset.Charset;

/**
 * Redis使用FastJson序列化
 * 
 * @author sg
 */
public class FastJsonRedisSerializer implements RedisSerializer
{

    public static final Charset DEFAULT_CHARSET = Charset.forName("UTF-8");

    private Class clazz;

    static
    {
        ParserConfig.getGlobalInstance().setAutoTypeSupport(true);
    }

    public FastJsonRedisSerializer(Class clazz)
    {
        super();
        this.clazz = clazz;
    }

    @Override
    public byte[] serialize(T t) throws SerializationException
    {
        if (t == null)
        {
            return new byte[0];
        }
        return JSON.toJSONString(t, SerializerFeature.WriteClassName).getBytes(DEFAULT_CHARSET);
    }

    @Override
    public T deserialize(byte[] bytes) throws SerializationException
    {
        if (bytes == null || bytes.length <= 0)
        {
            return null;
        }
        String str = new String(bytes, DEFAULT_CHARSET);

        return JSON.parseObject(str, clazz);
    }


    protected JavaType getJavaType(Class clazz)
    {
        return TypeFactory.defaultInstance().constructType(clazz);
    }
}

RedisConfig配置类,建议新建一个名为config包,把配置类放到该包中

import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.data.redis.connection.RedisConnectionFactory;
import org.springframework.data.redis.core.RedisTemplate;
import org.springframework.data.redis.serializer.StringRedisSerializer;

@Configuration
public class RedisConfig {

    @Bean
    @SuppressWarnings(value = { "unchecked", "rawtypes" })
    public RedisTemplate redisTemplate(RedisConnectionFactory connectionFactory)
    {
        RedisTemplate template = new RedisTemplate<>();
        template.setConnectionFactory(connectionFactory);

        FastJsonRedisSerializer serializer = new FastJsonRedisSerializer(Object.class);

        // 使用StringRedisSerializer来序列化和反序列化redis的key值
        template.setKeySerializer(new StringRedisSerializer());
        template.setValueSerializer(serializer);

        // Hash的key也采用StringRedisSerializer的序列化方式
        template.setHashKeySerializer(new StringRedisSerializer());
        template.setHashValueSerializer(serializer);

        template.afterPropertiesSet();
        return template;
    }
}

springMvc配置类,放到config包下

import com.alibaba.fastjson.serializer.SerializeConfig;
import com.alibaba.fastjson.serializer.SerializerFeature;
import com.alibaba.fastjson.serializer.ToStringSerializer;
import com.alibaba.fastjson.support.config.FastJsonConfig;
import com.alibaba.fastjson.support.spring.FastJsonHttpMessageConverter;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.http.converter.HttpMessageConverter;
import org.springframework.web.servlet.config.annotation.CorsRegistry;
import org.springframework.web.servlet.config.annotation.WebMvcConfigurer;
import java.util.List;

@Configuration
public class WebConfig implements WebMvcConfigurer {


//    跨域配置
    @Override
    public void addCorsMappings(CorsRegistry registry) {
      // 设置允许跨域的路径
        registry.addMapping("/**")
                // 设置允许跨域请求的域名
                .allowedOriginPatterns("*")
                // 是否允许cookie
                .allowCredentials(true)
                // 设置允许的请求方式
                .allowedMethods("GET", "POST", "DELETE", "PUT")
                // 设置允许的header属性
                .allowedHeaders("*")
                // 跨域允许时间
                .maxAge(3600);
    }


    @Bean//使用@Bean注入fastJsonHttpMessageConvert
    public HttpMessageConverter fastJsonHttpMessageConverters() {
        //1.需要定义一个Convert转换消息的对象
        FastJsonHttpMessageConverter fastConverter = new FastJsonHttpMessageConverter();
        FastJsonConfig fastJsonConfig = new FastJsonConfig();
        fastJsonConfig.setSerializerFeatures(SerializerFeature.PrettyFormat);
//        设置fastjson日期转换格式yyyy-mm-dd HH:mm:ss
        fastJsonConfig.setDateFormat("yyyy-MM-dd HH:mm:ss");

//        放开
        SerializeConfig.globalInstance.put(Long.class, ToStringSerializer.instance);

        fastJsonConfig.setSerializeConfig(SerializeConfig.globalInstance);
        fastConverter.setFastJsonConfig(fastJsonConfig);
        HttpMessageConverter converter = fastConverter;
        return converter;
    }

    @Override
    public void configureMessageConverters(List> converters) {
        converters.add(fastJsonHttpMessageConverters());
    }

}

③ 响应类,放到common包下

import lombok.Data;
import java.io.Serializable;
@Data
public class Result implements Serializable {

    private int code; // 200是正常,非200表示异常
    private String msg;
    private Object data;


    public static Result ok(Object data) {
        return ok(200, "操作成功", data);
    }

    public static Result ok() {
        return ok(200, "操作成功", null);
    }

    public static Result ok(int code, String msg, Object data) {
        Result r = new Result();
        r.setCode(code);
        r.setMsg(msg);
        r.setData(data);
        return r;
    }


    public static Result ok(int code, String msg){
        Result r = new Result();
        r.setCode(code);
        r.setMsg(msg);
        return r;
    }


    public static Result fail(String msg) {
        return fail(400, msg, null);
    }


    public static Result fail(String msg, Object data) {
        return fail(400, msg, data);
    }


    public static Result fail(int code, String msg, Object data) {
        Result r = new Result();
        r.setCode(code);
        r.setMsg(msg);
        r.setData(data);
        return r;
    }

}

④工具类,放到utils包下

import io.jsonwebtoken.Claims;
import io.jsonwebtoken.JwtBuilder;
import io.jsonwebtoken.Jwts;
import io.jsonwebtoken.SignatureAlgorithm;

import javax.crypto.SecretKey;
import javax.crypto.spec.SecretKeySpec;
import java.util.Base64;
import java.util.Date;
import java.util.UUID;
/**
 * JWT工具类
 */
public class JwtUtil {

    //有效期为
    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=JwtUtil.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();
    }

    public static void main(String[] args) throws Exception {
        String token = "eyJhbGciOiJIUzI1NiJ9.eyJqdGkiOiJjYWM2ZDVhZi1mNjVlLTQ0MDAtYjcxMi0zYWEwOGIyOTIwYjQiLCJzdWIiOiJzZyIsImlzcyI6InNnIiwiaWF0IjoxNjM4MTA2NzEyLCJleHAiOjE2MzgxMTAzMTJ9.JVsSbkP94wuczb4QryQbAke3ysBDIL5ou8fWsbt_ebg";
        Claims claims = parseJWT(token);
        System.out.println(claims);
    }

    /**
     * 生成加密后的秘钥 secretKey
     * @return
     */
    public static SecretKey generalKey() {
        byte[] encodedKey = Base64.getDecoder().decode(JwtUtil.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();
    }

}

redis封装工具类,放到utils包下


import java.util.*;
import java.util.concurrent.TimeUnit;
@SuppressWarnings(value = { "unchecked", "rawtypes" })
@Component
public class RedisCache
{
    @Autowired
    public RedisTemplate redisTemplate;

    /**
     * 缓存基本的对象,Integer、String、实体类等
     *
     * @param key 缓存的键值
     * @param value 缓存的值
     */
    public  void setCacheObject(final String key, final T value)
    {
        redisTemplate.opsForValue().set(key, value);
    }

    /**
     * 缓存基本的对象,Integer、String、实体类等
     *
     * @param key 缓存的键值
     * @param value 缓存的值
     * @param timeout 时间
     * @param timeUnit 时间颗粒度
     */
    public  void setCacheObject(final String key, final T value, final Integer timeout, final TimeUnit timeUnit)
    {
        redisTemplate.opsForValue().set(key, value, timeout, timeUnit);
    }

    /**
     * 设置有效时间
     *
     * @param key Redis键
     * @param timeout 超时时间
     * @return true=设置成功;false=设置失败
     */
    public boolean expire(final String key, final long timeout)
    {
        return expire(key, timeout, TimeUnit.SECONDS);
    }

    /**
     * 设置有效时间
     *
     * @param key Redis键
     * @param timeout 超时时间
     * @param unit 时间单位
     * @return true=设置成功;false=设置失败
     */
    public boolean expire(final String key, final long timeout, final TimeUnit unit)
    {
        return redisTemplate.expire(key, timeout, unit);
    }

    /**
     * 获得缓存的基本对象。
     *
     * @param key 缓存键值
     * @return 缓存键值对应的数据
     */
    public  T getCacheObject(final String key)
    {
        ValueOperations operation = redisTemplate.opsForValue();
        return operation.get(key);
    }

    /**
     * 删除单个对象
     *
     * @param key
     */
    public boolean deleteObject(final String key)
    {
        return redisTemplate.delete(key);
    }

    /**
     * 删除集合对象
     *
     * @param collection 多个对象
     * @return
     */
    public long deleteObject(final Collection collection)
    {
        return redisTemplate.delete(collection);
    }

    /**
     * 缓存List数据
     *
     * @param key 缓存的键值
     * @param dataList 待缓存的List数据
     * @return 缓存的对象
     */
    public  long setCacheList(final String key, final List dataList)
    {
        Long count = redisTemplate.opsForList().rightPushAll(key, dataList);
        return count == null ? 0 : count;
    }

    /**
     * 获得缓存的list对象
     *
     * @param key 缓存的键值
     * @return 缓存键值对应的数据
     */
    public  List getCacheList(final String key)
    {
        return redisTemplate.opsForList().range(key, 0, -1);
    }

    /**
     * 缓存Set
     *
     * @param key 缓存键值
     * @param dataSet 缓存的数据
     * @return 缓存数据的对象
     */
    public  BoundSetOperations setCacheSet(final String key, final Set dataSet)
    {
        BoundSetOperations setOperation = redisTemplate.boundSetOps(key);
        Iterator it = dataSet.iterator();
        while (it.hasNext())
        {
            setOperation.add(it.next());
        }
        return setOperation;
    }

    /**
     * 获得缓存的set
     *
     * @param key
     * @return
     */
    public  Set getCacheSet(final String key)
    {
        return redisTemplate.opsForSet().members(key);
    }

    /**
     * 缓存Map
     *
     * @param key
     * @param dataMap
     */
    public  void setCacheMap(final String key, final Map dataMap)
    {
        if (dataMap != null) {
            redisTemplate.opsForHash().putAll(key, dataMap);
        }
    }

    /**
     * 获得缓存的Map
     *
     * @param key
     * @return
     */
    public  Map getCacheMap(final String key)
    {
        return redisTemplate.opsForHash().entries(key);
    }

    /**
     * 往Hash中存入数据
     *
     * @param key Redis键
     * @param hKey Hash键
     * @param value 值
     */
    public  void setCacheMapValue(final String key, final String hKey, final T value)
    {
        redisTemplate.opsForHash().put(key, hKey, value);
    }

    /**
     * 获取Hash中的数据
     *
     * @param key Redis键
     * @param hKey Hash键
     * @return Hash中的对象
     */
    public  T getCacheMapValue(final String key, final String hKey)
    {
        HashOperations opsForHash = redisTemplate.opsForHash();
        return opsForHash.get(key, hKey);
    }

    /**
     * 删除Hash中的数据
     * 
     * @param key
     * @param hkey
     */
    public void delCacheMapValue(final String key, final String hkey)
    {
        HashOperations hashOperations = redisTemplate.opsForHash();
        hashOperations.delete(key, hkey);
    }

    /**
     * 获取多个Hash中的数据
     *
     * @param key Redis键
     * @param hKeys Hash键集合
     * @return Hash对象集合
     */
    public  List getMultiCacheMapValue(final String key, final Collection hKeys)
    {
        return redisTemplate.opsForHash().multiGet(key, hKeys);
    }

    /**
     * 获得缓存的基本对象列表
     *
     * @param pattern 字符串前缀
     * @return 对象列表
     */
    public Collection keys(final String pattern)
    {
        return redisTemplate.keys(pattern);
    }
} 
  
import javax.servlet.http.HttpServletResponse;
import java.io.IOException;

public class WebUtils
{
    /**
     * 将字符串渲染到客户端
     * 
     * @param response 渲染对象
     * @param string 待渲染的字符串
     * @return null
     */
    public static String renderString(HttpServletResponse response, String string) {
        try
        {
            response.setStatus(200);
            response.setContentType("application/json");
            response.setCharacterEncoding("utf-8");
            response.getWriter().print(string);
        }
        catch (IOException e)
        {
            e.printStackTrace();
        }
        return null;
    }
}

⑤实体类

import java.util.Date;
import com.baomidou.mybatisplus.annotation.TableName;
import lombok.AllArgsConstructor;
import lombok.Data;
import lombok.NoArgsConstructor;
/**
 * (SysUser)表实体类
 * @author makejava
 * @since 2023-01-11 14:55:46
 */
@SuppressWarnings("serial")
@Data
@AllArgsConstructor
@NoArgsConstructor
@TableName("sys_user")
public class  SysUser  {
    
    private Long id;
    
    private String username;
    
    private String password;
    
    private String avatar;
    
    private String email;
    
    private String city;
    
    private Date created;
    
    private Date updated;
    
    private Date lastLogin;
    
    private Integer statu;
    
}

4.实现

4.1准备工作

(1)向数据库中添加用户表,向表中添加一条用户记录

CREATE TABLE `sys_user` (
  `id` bigint(20) NOT NULL AUTO_INCREMENT,
  `username` varchar(64) DEFAULT NULL,
  `password` varchar(64) DEFAULT NULL,
  `avatar` varchar(255) DEFAULT NULL,
  `email` varchar(64) DEFAULT NULL,
  `city` varchar(64) DEFAULT NULL,
  `created` datetime DEFAULT NULL,
  `updated` datetime DEFAULT NULL,
  `last_login` datetime DEFAULT NULL,
  `statu` int(5) NOT NULL,
  PRIMARY KEY (`id`),
  UNIQUE KEY `UK_USERNAME` (`username`) USING BTREE
) ENGINE=InnoDB AUTO_INCREMENT=7 DEFAULT CHARSET=utf8;

INSERT INTO `sys_user` VALUES ('1', 'admin', '$2a$10$R7zegeWzOXPw871CmNuJ6upC0v8D373GuLuTw8jn6NET4BkPRZfgK', 'https://image-1300566513.cos.ap-guangzhou.myqcloud.com/upload/images/5a9f48118166308daba8b6da7e466aab.jpg', '[email protected]', '广州', '2021-01-12 22:13:53', '2021-01-16 16:57:32', '2020-12-30 08:38:37', '1');

(2)引入MybatisPuls和mysql驱动的依赖

  
            com.baomidou
            mybatis-plus-boot-starter
            3.4.3
        
        
            mysql
            mysql-connector-java
        

(3) 配置数据库信息,在appliction.yml文件中配置,注意修改数据库名称,用户名与密码为你自己本人的,我在vue_admin数据库中创建的sys_user表,我本人mysql客户端用户名为root,密码为mss

server:
  port: 8081
spring:
  datasource:
    driver-class-name: com.mysql.cj.jdbc.Driver
    url: jdbc:mysql://localhost:3306/vue_admin?useUnicode=true&useSSL=false&characterEncoding=utf8&serverTimezone=Asia/Shanghai
    username: root
    password: mss

(4) 定义Mapper接口,Service接口

import com.baomidou.mybatisplus.core.mapper.BaseMapper;
import com.mss.domain.entity.SysUser;
import org.apache.ibatis.annotations.Mapper;

@Mapper
public interface SysUserMapper extends BaseMapper {


}
import com.baomidou.mybatisplus.extension.service.IService;
import com.mss.common.Result;
import com.mss.domain.entity.SysUser;
import com.mss.domain.vo.SysUserVo;

public interface SysUserService extends IService {

}

4.2具体实现

(1)创建一个类 UserDetailsServiceImpl去实现UserDetailsService接口,重写其中的方法。方法里根据用户名从数据库中查询用户信息

@Service
public class UserDetailsServiceImpl implements UserDetailsService {

    @Autowired
    private SysUserService sysUserService;

    @Override
    public UserDetails loadUserByUsername(String username) throws UsernameNotFoundException {
        //根据用户名查询用户信息
        LambdaQueryWrapper qw = new LambdaQueryWrapper<>();
        qw.eq(SysUser::getUsername,username);
        SysUser user = sysUserService.getOne(qw);
        
        //如果查询不到数据就通过抛出异常来给出提示
        if(Objects.isNull(user))
        {
            throw new RuntimeException("该用户不存在");
        }
        
        //封装成UserDetails对象返回 
        return new LoginUser(user);
    }
}

(2)因为UserDetailsService方法的返回值是UserDetails接口类型,所以需要定义一个类,实现该接口,把用户信息封装在其中。我们自定义一个类LoginUser去实现该接口,重写其中的方法,添加用户信息必要字段

@Data
@NoArgsConstructor
public class LoginUser implements UserDetails {

    /**
     * 用户信息
     */
    private SysUser user;

    public SysUser getUser() {
        return user;
    }

    public void setUser(SysUser user) {
        this.user = user;
    }

    public LoginUser(SysUser user) {
      
        this.user = user;
    }

    @Override
    public Collection 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;
    }
}

(3)配置springsecurity密码加密存储

实际项目中我们不会把密码明文存储在数据库中。

默认使用的PasswordEncoder要求数据库中的密码格式为:{id}password 。它会根据id去判断密码的加密方式。但是我们一般不会采用这种方式。所以就需要替换PasswordEncoder。

我们一般使用SpringSecurity为我们提供的BCryptPasswordEncoder。

我们只需要使用把BCryptPasswordEncoder对象注入Spring容器中,SpringSecurity就会使用该PasswordEncoder来进行密码校验。

我们可以定义一个SpringSecurity的配置类,SpringSecurity要求这个配置类要继承WebSecurityConfigurerAdapter。

@Configuration
public class SecurityConfig extends WebSecurityConfigurerAdapter {

    @Bean
    public PasswordEncoder passwordEncoder(){
        return new BCryptPasswordEncoder();
    }

}

(4)登陆接口与相关方法的实现

接下我们需要自定义登陆接口,然后让SpringSecurity对这个接口放行,让用户访问这个接口的时候不用登录也能访问。

在接口中我们通过AuthenticationManager的authenticate方法来进行用户认证,所以需要在SecurityConfig中配置把AuthenticationManager注入容器。

认证成功的话要生成一个jwt,放入响应中返回。并且为了让用户下回请求时能通过jwt识别出具体的是哪个用户,我们需要把用户信息存入redis,可以把用户id作为key。

@RequestMapping("/system")
@RestController
public class LoginController {

    @Autowired
    private SysLoginService sysLoginService;

    @PostMapping("/login")
    public Result login(@RequestBody SysUser user){
        return sysloginServcie.login(user);
    }
}

修改SecurityConfig配置类,重写configure方法,对登录接口放行

@Configuration
public class SecurityConfig extends WebSecurityConfigurerAdapter {


    @Bean
    public PasswordEncoder passwordEncoder(){
        return new BCryptPasswordEncoder();
    }

    
    @Override
    protected void configure(HttpSecurity http) throws Exception {
        http
                //关闭csrf
                .csrf().disable()
                //不通过Session获取SecurityContext
                .sessionManagement()
                .sessionCreationPolicy(SessionCreationPolicy.STATELESS)
                .and()
                .authorizeRequests()
                // 对于登录接口 允许匿名访问
                .antMatchers("/system/login").anonymous()
                // 除上面外的所有请求全部需要鉴权认证
                .anyRequest().authenticated();
    }

    @Bean
    @Override
    public AuthenticationManager authenticationManagerBean() throws Exception {
        return super.authenticationManagerBean();
    }
}

自定义登录类,其中有登录方法login,登录方法中实现具体登录逻辑

@Component
public class SysLoginService {

    @Autowired
    private AuthenticationManager authenticationManager;

    @Autowired
    private RedisCache redisCache;

    public Result login(SysUser sysUser) {

//        用户验证
        Authentication authentication=null;

            UsernamePasswordAuthenticationToken authenticationToken =
            new UsernamePasswordAuthenticationToken(sysUser.getUsername(), 
            sysUser.getPassword());


            // authenticate该方法会去调用UserDetailsServiceImpl.loadUserByUsername方法
//             在该方法中进行用户名的校验
            authentication = authenticationManager.authenticate(authenticationToken);

            if(Objects.isNull(authentication)){

                throw new RuntimeException("用户名或者密码错误!");
            }

//        生成token返回前端

//        SecurityContextHolder.getContext().getAuthentication().getPrincipal()等价于
//        authenticationManager.authenticate(authenticationToken).getPrincipal()

//        获取security存储登录用户的信息
        LoginUser loginUser = (LoginUser) authentication.getPrincipal();
        String userId = loginUser.getUser().getId().toString();
        String token = JwtUtil.createJWT(userId);

        //把用户信息存入redis
        redisCache.setCacheObject("login" +userId,loginUser);

//        封装token信息,并且返回
        Map map = new HashMap();
        map.put("token",token);
        return Result.ok(map);

    }

(5)添加token认证过滤器

我们需要自定义一个类JwtAuthenticationTokenFilter,继承SpringSecurity给我们提供的OncePerRequestFilter这个过滤器类,把他变成我们想要的过滤器,这个过滤器会去获取请求头中的token,对token进行解析取出其中的userid。

在token认证过滤器类JwtAuthenticationTokenFilter中,使用userid去redis中获取对应的LoginUser对象。然后封装Authentication对象存入SecurityContextHolder

@Component
public class JwtAuthenticationTokenFilter extends OncePerRequestFilter {

    @Autowired
    private RedisCache redisCache;

    @Override
    protected void doFilterInternal(HttpServletRequest request, 
    HttpServletResponse response, FilterChain filterChain)
    throws ServletException, IOException {
        //获取token
        String token = request.getHeader("token");
        
        // 判断token是否存在,若不存在则为第一次登录,直接放行;
        //若存在则为第一次登录后的再次访问,需要进行token解析校验用户身份
        if (!StringUtils.hasText(token)) {
            //放行
            filterChain.doFilter(request, response);
            return;
        }
        //解析token
        String userid;
        try {
            Claims claims = JwtUtil.parseJWT(token);
            userid = claims.getSubject();
        } catch (Exception e) {
            e.printStackTrace();
            throw new RuntimeException("token非法");
        }
        //从redis中获取用户信息
        String redisKey = "login" + userid;
        LoginUser loginUser = redisCache.getCacheObject(redisKey);
        if(Objects.isNull(loginUser)){
            throw new RuntimeException("用户未登录");
        }
        //存入SecurityContextHolder
        //TODO 获取权限信息封装到Authentication中
        UsernamePasswordAuthenticationToken authenticationToken =
                new UsernamePasswordAuthenticationToken(loginUser,null,null);
        SecurityContextHolder.getContext().setAuthentication(authenticationToken);
        //放行
        filterChain.doFilter(request, response);
    }
}

最后配置我们自定义的token过滤器JwtAuthenticationTokenFilter在整个springsecurity过滤链中的具体位置,放在UsernamePasswordAuthenticationFilter过滤器的前面,修改SecurityConfig配置类如下:

@Configuration
public class SecurityConfig extends WebSecurityConfigurerAdapter {

    @Bean
    public PasswordEncoder passwordEncoder(){
        return new BCryptPasswordEncoder();
    }

    @Autowired
    JwtAuthenticationTokenFilter jwtAuthenticationTokenFilter;

    @Override
    protected void configure(HttpSecurity http) throws Exception {
        http
                //关闭csrf
                .csrf().disable()
                //不通过Session获取SecurityContext
                .sessionManagement().sessionCreationPolicy(SessionCreationPolicy.STATELESS)
                .and()
                .authorizeRequests()
                // 对于登录接口 允许匿名访问
                .antMatchers("/user/login").anonymous()
                // 除上面外的所有请求全部需要鉴权认证
                .anyRequest().authenticated();

//        把token认证过滤器添加到springsecurity过滤器链中,并且配置
//        token认证过滤器jwtAuthenticationTokenFilter在过滤器链的具体拦截位置,
//        该过滤器的位置放在UsernamePasswordAuthenticationFilter过滤器的前面
        http.addFilterBefore(jwtAuthenticationTokenFilter, 
        UsernamePasswordAuthenticationFilter.class);
    }

    @Bean
    @Override
    public AuthenticationManager authenticationManagerBean() throws Exception {
        return super.authenticationManagerBean();
    }
}

(6)启动springboot项目演示

登录页面,下面给出的登录页面是使用vue和element-ui组件库写的,需要自己创建一个vue工程,创建vue工程时建议引入vue-Router,一边登录成功后的路由跳转,创建好vue工程后再引入element-ui组件库,不会创建vue项目可以参考博主

入花甲还要写代码的博客_CSDN博客-JavaScript,vue,vuecli领域博主

的文章,链接如下:

如何搭建一个vue项目(完整步骤)_入花甲还要写代码的博客-CSDN博客_vue项目搭建

引入element-ui组件库的文章介绍链接如下:

Vue项目引入引入ElementUI_Tina-Deng的博客-CSDN博客_vue引入element ui

simplelogin.vue

(注意直接复制粘贴会因为缺少相应位置的图片文件而无法展示,

:src="require('@/assets/logo.png')",你需要在src目录下的assets目录下添加自己的图片文件,把文件名换成自己添加的图片文件名即可,assets目录下添加的图片文件为logo.png)




login.js

//引入utils包下的request.js axios配置文件
import request from '@/utils/request'

export function baselogin(loginfrom){
    return request({
        url:'/system/login',
        method:'post',
        data:loginfrom
    })
}

request.js

import axios from 'axios'
import store from '../store'
import Element from 'element-ui'
import router from "../router"
axios.defaults.headers['Content-Type'] = 'application/json;charset=utf-8'

// 创建axios实例
const service = axios.create({
    // axios中请求配置有baseURL选项,表示请求URL公共部分

    baseURL: 'http://localhost:8081/',
    // 超时
    timeout: 10000

})

//配置请求拦截器
service.interceptors.request.use(config => {
    config.headers['token'] = localStorage.getItem("token") // 请求头带上token
    return config
})

//配置响应拦截器
service.interceptors.response.use(response => {
        let res = response.data;
        if (res.code === 200) {
            return response
        } else {
            Element.Message.error(res.msg? res.msg : '系统异常!', {duration: 3 * 1000})
            return Promise.reject(response.data.msg)
        }
    },
    error => {
        console.log(error)

        if(error.response.data) {
            error.message = error.response.data.msg
        }
        if(error.response.status === 401) {
            router.push("/login")
        }
        Element.Message.error(error.message, {duration: 3 * 1000})
        return Promise.reject(error)
    }
)
export default service

vue项目src目录下的Router文件夹下的路由配置文件index.js

import Vue from 'vue'
import VueRouter from 'vue-router'

Vue.use(VueRouter)

const constantRoutes=[

    {
        path:'/baselogin',
        name:'baselogin',
        component: ()=>import('../views/simplelogin'),
        hidden: true
    },
    {
    path:'/abc',
    name:'abc',
    component: ()=>import('../views/abc'),
    hidden: true
    }

]
const createRouter = () => new VueRouter({
    mode: 'history',
    base: process.env.BASE_URL,
    routes: constantRoutes
})

const router = createRouter()

// Detail see: https://github.com/vuejs/vue-router/issues/1234#issuecomment-357941465
export function resetRouter() {
    const newRouter = createRouter()
    router.matcher = newRouter.matcher // reset router
}

运行我们创建的springboot项目的启动类和启动vue项目,访问localhsot:8080/baselogin,填写登录界面下的表单。在访问在访问接口之前我们来看一下这一顿操作下来的springboot项目的结果目录

前后端分离项目SpringBoot+SpringSecurity 图片验证码登录功能的实现(详细)_第5张图片

登录界面展示

前后端分离项目SpringBoot+SpringSecurity 图片验证码登录功能的实现(详细)_第6张图片

输入正确用户名与密码,点击登录

前后端分离项目SpringBoot+SpringSecurity 图片验证码登录功能的实现(详细)_第7张图片

 跳转到/abc路由所指定的组件页面abc.vue

前后端分离项目SpringBoot+SpringSecurity 图片验证码登录功能的实现(详细)_第8张图片

到目前为止,不带验证码校验的基础登录功能就已经实现了,下面我们来看看带验证码校验的登录功能的实现。

2.图片验证码登录功能的实现

终于来到了我们本篇博客的重点介绍内容了。附带图片验证码登录功能其实就是在基础登录功能上添加图片验证码验证校验这一功能,建议先把不带验证码校验的基础登录功能先跟着敲一遍。

接下来我们先来梳理一下带验证码的登录交互流程

登录交互过程

  1. 浏览器打开登录页面

  2. 动态加载登录验证码,因为这是前后端分离的项目,我们不再使用session进行交互,所以后端我打算禁用session,那么验证码的验证就是问题了,所以后端设计上我打算生成验证码同时使用uuid生成一个随机码,随机码作为key,验证码为value保存到redis中,然后把随机码和验证码图片的Base64字符串码发送到前端

  3. 前端提交用户名、密码、验证码还有随机码

  4. 后台验证验证码是否匹配以及密码是否正确

前后端分离项目SpringBoot+SpringSecurity 图片验证码登录功能的实现(详细)_第9张图片

看完了登录流程,我们就来看看前端vue环境的搭建与后端环境的搭建

前端vue环境的搭建

vue项目的搭建在这里就不过多介绍了,不会的可以往前翻,我在不带验证码校验的基础登录功能部分有介绍,这里只提供一些必要代码。

前端登录页面 login.vue



login.js

import request from '@/utils/request'

export function login(loginForm){
    return request({
        url:'system/user/login',
        method:'post',
        data:loginForm

    })
}

export function captcha(){
    return request({
        url:'/captcha',
        method:'get'
    })
}

vue项目src目录下的Router文件夹下的路由配置文件index.js

import Vue from 'vue'
import VueRouter from 'vue-router'

Vue.use(VueRouter)

const constantRoutes=[

    {
        path:'/baselogin',
        name:'baselogin',
        component: ()=>import('../views/simplelogin'),
        hidden: true
    },
   {
        path:'/login',
        name:'login',
        component: ()=>import('../viewslogin'),
        hidden: true
    },
    {
    path:'/abc',
    name:'abc',
    component: ()=>import('../views/abc'),
    hidden: true
    }

]
const createRouter = () => new VueRouter({
    mode: 'history',
    base: process.env.BASE_URL,
    routes: constantRoutes
})

const router = createRouter()

// Detail see: https://github.com/vuejs/vue-router/issues/1234#issuecomment-357941465
export function resetRouter() {
    const newRouter = createRouter()
    router.matcher = newRouter.matcher // reset router
}

后端环境的搭建

后端环境是在不带验证码校验的基础登录功能的springboot项目上做修改即可,建议把不带验证码校验的基础登录功能先跟着做过一遍在来看,也可以把不带验证码校验的基础登录功能部分所需的工具类和通用类跟着复制粘贴搭建成一个项目骨架。

验证码的生成我们使用谷歌验证码生成器captcha,它是一个非常实用的验证码生成工具,可以通过配置生成多样化的验证码。以图片的形式显示,从而无法进行复制粘贴。校验验证码是否正确的这一逻辑可以集成在登录SysLoginService类的登录方法login里实现,也可以自定义一个验证码拦截器类进行拦截校验,这里我们介绍的是前者。接下来我们一步一步介绍

1.引入Maven依赖


        
            com.github.axet
            kaptcha
            0.0.9
        

2.生成验证码配置类

import com.google.code.kaptcha.impl.DefaultKaptcha;
import com.google.code.kaptcha.util.Config;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import java.util.Properties;

//谷歌验证码图片生成器配置文件
@Configuration
public class KaptchaConfig {
   @Bean
   public DefaultKaptcha producer() {

      Properties properties = new Properties();
      properties.put("kaptcha.border", "no");
      properties.put("kaptcha.textproducer.font.color", "black");
      properties.put("kaptcha.textproducer.char.space", "4");
      properties.put("kaptcha.image.height", "40");
      properties.put("kaptcha.image.width", "120");
      properties.put("kaptcha.textproducer.font.size", "30");

      Config config = new Config(properties);
      DefaultKaptcha defaultKaptcha = new DefaultKaptcha();
      defaultKaptcha.setConfig(config);
      return defaultKaptcha;
    }
}

   

3.添加验证码控制器,在控制器里添加生成验证码方法

import com.google.code.kaptcha.impl.DefaultKaptcha;
import com.mss.common.Result;
import com.mss.constant.CaptchaConstant;
import com.mss.utils.RedisCache;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.RestController;
import sun.misc.BASE64Encoder;
import javax.imageio.ImageIO;
import java.awt.image.BufferedImage;
import java.io.ByteArrayOutputStream;
import java.io.IOException;
import java.util.HashMap;
import java.util.UUID;

@RestController
public class captchaController {
    
    @Autowired
    public DefaultKaptcha producer;

    @Autowired
    private RedisCache redisCache;


   @GetMapping("/captcha")
    public Result getCaptcha() throws IOException {

//     生成uuid、生成验证码,返回uuid和验证码图片给前端
       String uuid = UUID.randomUUID().toString();
       String captcha = producer.createText();


       BufferedImage image = producer.createImage(captcha);

       ByteArrayOutputStream outputStream = new ByteArrayOutputStream();

       ImageIO.write(image, "jpg", outputStream);

       BASE64Encoder encoder = new BASE64Encoder();
       String str = "data:image/jpeg;base64,";
       String base64Img = str + encoder.encode(outputStream.toByteArray());

       // 存储到redis中,所以redis中的map数据结构存储
       redisCache.setCacheMapValue("captcha",uuid,captcha);

       HashMap map = new HashMap<>();
       map.put("uuid",uuid);
       map.put("base64Image",base64Img);

       return Result.ok(map);
   }

}

4.修改登录控制器类loginController中的login方法,添加相关vo类


@RestController
@RequestMapping(value="/system/user")
public class loginController {
    
@PostMapping("/login")
    public Result login(@RequestBody SysUserVo sysUserVo){

        return sysLoginService.login(sysUserVo);

    }
}

SysUserVo.java

@Data
@AllArgsConstructor
@NoArgsConstructor
public class SysUserVo{


    private Long   id;
    public  String username;
    private String phone;
    private String code;
    private String uuid;
    private String password;
    private String avatar;
    private String email;
    private String city;
    private Date created;
    private List roles;
    private Integer statu;

}

5.修改SysLoginService,在SysLoginService类的login方法里校验验证码是否正确, 添加自定义验证码异常类CaptchaException

@Component
public class SysLoginService {

    @Autowired
    private AuthenticationManager authenticationManager;

    @Autowired
    private RedisCache redisCache;

    public Result  login(SysUserVo sysUserVo) {

//        校验验证码
        validate(sysUserVo);

//        用户验证
        Authentication authentication=null;

            UsernamePasswordAuthenticationToken authenticationToken =
            new UsernamePasswordAuthenticationToken(sysUserVo.getUsername(), sysUserVo.getPassword());

//            存储当前用户信息到自定义身份验证类AuthenticationContextHolder中
            AuthenticationContextHolder.setContext(authenticationToken);


            // authenticate该方法会去调用UserDetailsServiceImpl.loadUserByUsername方法
//             在该方法中进行用户名的校验与密码的校验
            authentication = authenticationManager.authenticate(authenticationToken);

            if(Objects.isNull(authentication)){

                throw new RuntimeException("用户名或者密码错误!");
            }

//        生成token返回前端

//        SecurityContextHolder.getContext().getAuthentication().getPrincipal()等价于
//        authenticationManager.authenticate(authenticationToken).getPrincipal()
//        都是获取security存储登录用户的信息

        LoginUser loginUser = (LoginUser) authentication.getPrincipal();
        String userId = loginUser.getUser().getId().toString();
        String token = JwtUtil.createJWT(userId);

        //把用户信息存入redis
        redisCache.setCacheObject("login:userId" +userId,loginUser);

//        封装token信息,并且返回
        Map map = new HashMap();
        map.put("token",token);
        return Result.ok(map);

    }


    //验证码校验方法
    private void validate(SysUserVo sysUserVo) {

        String code = sysUserVo.getCode();
        String uuid = sysUserVo.getUuid();

        if (StringUtils.isBlank(code) || StringUtils.isBlank(uuid)) {
            throw new CaptchaException("验证码不能为空");
        }

        String captcha =(String) redisCache.getCacheMapValue("captcha", uuid);
        if(!code.equals(captcha)){
            throw new CaptchaException("验证码不正确");
        }

        // 一次性使用,使用后删除
        redisCache.delCacheMapValue("captcha", uuid);

        System.out.println("验证码正确!!!");
    }

}

 CaptchaException.java

public class CaptchaException extends RuntimeException {

    String msg;

    public CaptchaException(String msg) {
        this.msg = msg;
    }

    public String getMsg() {
        return msg;
    }

    public void setMsg(String msg) {
        this.msg = msg;
    }
}

最后我们启动项目来测试登录效果,先访问前端项目登录页面localhost:8080/login,登录页面如下:

前后端分离项目SpringBoot+SpringSecurity 图片验证码登录功能的实现(详细)_第10张图片

输入正确用户名密码验证码,点击登录

前后端分离项目SpringBoot+SpringSecurity 图片验证码登录功能的实现(详细)_第11张图片

 登录成功跳转到abc.vue组件所展示页面

前后端分离项目SpringBoot+SpringSecurity 图片验证码登录功能的实现(详细)_第12张图片

到此,我们也就实现了带验证码校验的登录功能。

你可能感兴趣的:(spring,boot,spring,java,后端)