IT数码 购物 网址 头条 软件 日历 阅读 图书馆
TxT小说阅读器
↓语音阅读,小说下载,古典文学↓
图片批量下载器
↓批量下载图片,美女图库↓
图片自动播放器
↓图片自动播放器↓
一键清除垃圾
↓轻轻一点,清除系统垃圾↓
开发: C++知识库 Java知识库 JavaScript Python PHP知识库 人工智能 区块链 大数据 移动开发 嵌入式 开发工具 数据结构与算法 开发测试 游戏开发 网络协议 系统运维
教程: HTML教程 CSS教程 JavaScript教程 Go语言教程 JQuery教程 VUE教程 VUE3教程 Bootstrap教程 SQL数据库教程 C语言教程 C++教程 Java教程 Python教程 Python3教程 C#教程
数码: 电脑 笔记本 显卡 显示器 固态硬盘 硬盘 耳机 手机 iphone vivo oppo 小米 华为 单反 装机 图拉丁
 
   -> Java知识库 -> Spring Boot + Spring-Security实现前后端分离双重身份认证初学者指南(手机号密码JWT + 短信验证码) -> 正文阅读

[Java知识库]Spring Boot + Spring-Security实现前后端分离双重身份认证初学者指南(手机号密码JWT + 短信验证码)

折(mo)腾(yu)了好几天,终于把双重身份认证实现了。(账号密码jwt+短信验证码)

看了很多视频,照葫芦画瓢敲了两三次,遇到各种各样的bug,比如循环依赖(通过@PostConstructor+setter解决)、框架报错等,翻了上百次csdn才逐渐摸清。总算对spring-security有了一个大概的认识,写一点学习心得,希望能帮到初学者,同时以备自己未来复习。


spring-security的核心无非就在于一个AuthenticationManager。起辅助作用的有自上而下有Filter类、Provider类、Token类。项目结构中,Filter类在Controller类之上,我放在aop包下。Provider类实现核心登录功能,我放在core包下。Token类则是pojo,我放在domain包下,供参考。

我们目前只用上AuthenticationManager的默认实现ProviderManager类。它的核心在于其有一个List<AuthenticationProvider> providers,当外部调用它进行身份认证时,他会尝试调用这里的每个provider进行认证,并将结果返回。在配置类中可以修改这个provider列表,传入自定义的provider。


jwt认证实现的逻辑大致为:用户通过所有人(准确的说是匿名用户)都可以访问的登录api提交账号密码,Service层将其封装为token对象并调用AuthenticationManager进行身份认证,若成功则将用户信息写redis缓存,并返回一个token(这里特指token字符串)给前端。之后,用户访问其他需要身份认证的api时,只需请求头里携带该token,即可被Filter顺利放行。

要设置身份认证,首先要把所有没登录的用户拦截,而保留登录api给未登录用户。这就是配置类public class WebSecurityConfig extends WebSecurityConfigurerAdapter。打上@EnableGlobalMethodSecurity(prePostEnabled = true)注解后即可在各个api前,使用@PreAuthorize注解控制访问权限,而不必编写大量链式调用配置代码。这个注解常用参数有”permitAll()”(所有)、”isAnonymous()”(匿名,即未认证),”isAuthenticated()”(已认证)、"hasAuthority('xxx')"(特定身份登录)等。

此外,配置类中还需重写@Overrider protected void configure(@NotNull HttpSecurity http)方法进行配置。具体来说,前后端分离开发需关闭csrf与session功能:http.csrf().disable().sessionManagement().sessionCreationPolicy(SessionCreationPolicy.STATELESS);并将过滤器添加至框架自己的过滤器链中:http.addFilterBefore(jwtAuthenticationTokenFilter,UsernamePasswordAuthenticationFilter.class);一般还需要编写@Bean public PasswordEncoder passwordEncoder(),重写@Bean @Override public AuthenticationManager authenticationManagerBean()方法(返回父类方法)才能正常运行。

WebSecurityConfig.java

/**
 * Spring-Security配置
 * @author Icy
 * @version 1.0.0
 * @since 1.0.0
 */
@Configuration
@EnableWebSecurity
@EnableGlobalMethodSecurity(prePostEnabled = true)
public class WebSecurityConfig extends WebSecurityConfigurerAdapter {

    @Autowired
    private JwtAuthenticationTokenFilter jwtAuthenticationTokenFilter;

    @Autowired
    private SmsAuthenticationProvider smsAuthenticationProvider;

    @Autowired
    private UserDetailsService userDetailsService;

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

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

    @Bean
    public AuthenticationProvider authenticationProvider() {
        DaoAuthenticationProvider authenticationProvider = new DaoAuthenticationProvider();
        authenticationProvider.setUserDetailsService(userDetailsService);
        authenticationProvider.setPasswordEncoder(passwordEncoder());
        return authenticationProvider;
    }

    @Override
    protected void configure(@NotNull HttpSecurity http) throws Exception {
        // 关闭csrf与session功能
        http.csrf().disable().sessionManagement().sessionCreationPolicy(SessionCreationPolicy.STATELESS);
        // 将token校验过滤器与短信验证码过滤器添加到过滤器链中
        http.addFilterBefore(jwtAuthenticationTokenFilter, UsernamePasswordAuthenticationFilter.class);
    }

    @Override
    protected void configure(@NotNull AuthenticationManagerBuilder auth) {
        auth.authenticationProvider(authenticationProvider());
        auth.authenticationProvider(smsAuthenticationProvider);
    }
}

传统账号认证可不用自行编写provider,实现UserDetailsService供框架查询用户密码和权限信息即可。具体来说重写@Override public UserDetails loadUserByUsername(String s)方法,调用DAO层查找用户,并使用装饰器模式将普通UserPO包装为User实体实现UserDetails。需要做的一些工作:重写@Override public Collection<? extends GrantedAuthority> getAuthorities()方法将List<String>权限列表映射为框架提供的权限SimpleGrantedAuthority类:authorities = roleList.stream().map(SimpleGrantedAuthority::new).collect(Collectors.toList());并且设置@JSONField(serialize = false) private List<SimpleGrantedAuthority> authorities不进行JSON序列化,防止redis存取出现异常。此外,重写@Override public String getUsername()方法按需返回,比如手机号密码登录就返回原UserPO的phone。

AuthServiceImpl.java

    @Override
    public UserDetails loadUserByUsername(@Validated @NotEmpty String s) {
        // 查询用户信息
        UserPO userPO = userRepository.selectByPhone(s);
        if (userPO == null) {
            throw new DbException(ServiceCode.USER_NOT_FOUND);
        }

        List<String> roleList = userRepository.listRoleById(userPO.getUserId());
        // 把数据封装成UserDetails返回
        return new User(userPO, roleList);
    }

UserPO.java

/**
 * t_user表
 * @author Icy
 * @version 1.0.0
 * @since 1.0.0
 */
@Data
@Builder
@NoArgsConstructor
@AllArgsConstructor
@TableName("t_user")
public class UserPO implements Serializable {

    private static final long serialVersionUID = 3042112031982669233L;

    @TableId(type = IdType.AUTO)
    private Long userId;

    @TableField
    private Date createTime;

    @TableField
    private Date updateTime;

    @TableField("user_name")
    private String name;

    @TableField("user_phone")
    private String phone;

    @TableField
    private Long schoolId;

    @TableField("user_password")
    private String password;

    @TableField("user_qq")
    private String QQ;

    @TableField("user_email")
    private String email;
}

User.java

/**
 * 身份认证业务用户实体类
 * @author Icy
 * @version 1.0.0
 * @since 1.0.0
 */
@Data
public class User implements UserDetails {

    private static final long serialVersionUID = -6634318190888926617L;

    private UserPO user;

    private List<String> roleList;

    // 只需序列化roleList即可,否则存入redis出现异常
    @JSONField(serialize = false)
    private List<SimpleGrantedAuthority> authorities;

    public User(UserPO user, List<String> roleList) {
        this.user = user;
        this.roleList = roleList;
    }

    @Override
    public Collection<? extends GrantedAuthority> getAuthorities() {
        if (authorities != null) {
            return authorities;
        }

        // 将roleList中String类型权限信息封装成SimpleGrantedAuthority对象
        authorities = roleList.stream().map(SimpleGrantedAuthority::new).collect(Collectors.toList());
        return authorities;
    }

    @Override
    public String getPassword() {
        return user.getPassword();
    }

    // 使用Spring-Security框架的“用户名”实际上是手机号
    @Override
    public String getUsername() {
        return user.getPhone();
    }

    @Override
    public boolean isAccountNonExpired() {
        return true;
    }

    @Override
    public boolean isAccountNonLocked() {
        return true;
    }

    @Override
    public boolean isCredentialsNonExpired() {
        return true;
    }

    @Override
    public boolean isEnabled() {
        return true;
    }
}

用户登录后得到的token如何起作用?我们需要重写一个public class JwtAuthenticationTokenFilter extends OncePerRequestFilter并将其在配置类中加入过滤器链即可。重写protected void doFilterInternal(HttpServletRequest request, HttpServletResponse response, @NotNull FilterChain filterChain)方法,从请求头获取token,无token则进行拦截(即return返回):调用filterChain通知其他拦截器工作,最后返回。有token则使用JWTUtil进行解析(我使用了hutool提供的工具类),读redis缓存用户数据(若无则报错返回)(特别需要注意:Filter层位于Controller层之上,不可抛出异常,不会被Controller层中全局Advice捕获,需封装原生方法写入response)。成功读取后,创建Token并存入spring-security框架SecurityContextHolder即可。

JwtAuthenticationFilter.java

/**
 * JWT令牌过滤器
 * @apiNote 注意:该层不可抛异常,需调用WriteJSON方法写回
 * @author Icy
 * @version 1.0.0
 * @since 1.0.0
 */
@Component
@AllArgsConstructor
public class JwtAuthenticationTokenFilter extends OncePerRequestFilter {

    private final RedisUtil redisUtil;

    private final WriterUtil writerUtil;

    @Override
    protected void doFilterInternal(
            @NotNull HttpServletRequest request,
            @NotNull HttpServletResponse response,
            @NotNull FilterChain filterChain) throws ServletException, IOException {
        String token = request.getHeader("token");
        if (StrUtil.isEmpty(token)) {
            // 无token,拦截
            filterChain.doFilter(request, response);
            return;
        }

        try {
            // 解析token
            String uid = (String)JWTUtil.parseToken(token).getPayload("uid");
            // alibaba的JsonObject,只能用Object接收,否则报强转异常
            Object json = redisUtil.get("login:" + uid);
            // 读redis
            if (json == null) {
                // 用户未登录。不能抛异常,Filter层在Advice之上。
                writerUtil.WriteJSON(response, new ResponseVO(ApiCode.AUTH_FAILURE));
                return;
            }

            User user = JSON.parseObject(json.toString(), User.class);

            UsernamePasswordAuthenticationToken authenticationToken =
                    new UsernamePasswordAuthenticationToken(user, null, user.getAuthorities());
            SecurityContextHolder.getContext().setAuthentication(authenticationToken);

        } catch (Exception e) {
            // token不合法。不能抛异常,Filter层在Advice之上。
            writerUtil.WriteJSON(response, new ResponseVO(ApiCode.AUTH_ILLEGAL_TOKEN));
            return;
        }

        // 处理完,放行
        filterChain.doFilter(request, response);
    }
}

至于登出,从SecurityContextHolder获取Token对象得到userid等信息,清除redis中对jwt的缓存即可。

?AuthServiceImpl.java

    @Override
    public void logout() {
        // 获取用户id
        UsernamePasswordAuthenticationToken authentication =
                (UsernamePasswordAuthenticationToken)SecurityContextHolder.getContext().getAuthentication();
        User user = (User)authentication.getPrincipal();
        // 如果无值会被Filter拦截,无需ifn判断
        Long uid = user.getUser().getUserId();
        redisUtil.delete("login:" + uid);
    }

若需多种登录认证,首先需要提供传统账号密码登录的provider,在配置类中通过@Bean public AuthenticationProvider authenticationProvider()方法实现,具体来说,一般使用框架自带的DaoAuthenticationProvider,使用setter设置UserDetailsService(供框架查询用户密码和权限信息)、PasswordEncoder(一般为BCrypt)即可返回。还需重写@Override protected void configure(@NotNull AuthenticationManagerBuilder auth)方法,设置使用的一系列provider。

看我干嘛,完整配置类代码上面给了

下面以短信验证码举例。短信验证码发送使用腾讯云申请个人密钥,调用sdk发送即可,同时将验证码写入redis缓存。具体参考第三方文档,这里不再赘述。首先需要重写一个自定义Provider:public class SmsAuthenticationProvider implements AuthenticationProvider,具体重写其中的@Override public Authentication authenticate(Authentication authentication)方法,读token对象中信息与redis缓存信息是否吻合,否则抛出异常返回。成功后,创建新的认证对象,并交由service写入redis,返回token。

SmsAuthenticationProvider.java

/**
 * 短信验证码登录认证逻辑
 * @author Icy
 * @version 1.0.0
 * @since 1.0.0
 */
@Component
public class SmsAuthenticationProvider implements AuthenticationProvider {

    @Autowired
    private GrantedAuthoritiesMapper authoritiesMapper;

    @Autowired
    private UserDetailsService userDetailsService;

    @Autowired
    private AuthService authService;

    @Bean
    public GrantedAuthoritiesMapper grantedAuthoritiesMapper() {
        return new NullAuthoritiesMapper();
    }

    @Override
    public Authentication authenticate(Authentication authentication) throws AuthenticationException {
        if (!(authentication instanceof SmsVerifyCodeAuthenticationToken)) {
            throw new ApiException();
        }
        SmsVerifyCodeAuthenticationToken smsVerifyCodeAuthenticationToken =
                (SmsVerifyCodeAuthenticationToken) authentication;
        String phone = smsVerifyCodeAuthenticationToken.getName();
        String code = smsVerifyCodeAuthenticationToken.getVerifyCode();
        UserDetails user = userDetailsService.loadUserByUsername(phone);
        if (user == null) {
            throw new ServiceException(ServiceCode.USER_NOT_FOUND);
        }

        // 验证码校验,失败抛异常返回,成功创建新token(认证后)
        authService.codeVerify(phone, code);
        return createSuccessAuthentication(authentication, user);
    }

    // 当前Provider仅支持校验短信验证码token
    @Override
    public boolean supports(Class<?> authentication) {
        return SmsVerifyCodeAuthenticationToken.class.isAssignableFrom(authentication);
    }

    /**
     * 认证成功,将非认证token转为认证token
     * @param authentication 身份认证
     * @param user 用户信息
     * @return 认证token
     */
    protected Authentication createSuccessAuthentication(
            @NotNull Authentication authentication,
            @NotNull UserDetails user) {
        Collection<? extends GrantedAuthority> authorities = authoritiesMapper.mapAuthorities(user.getAuthorities());
        SmsVerifyCodeAuthenticationToken authenticationToken =
                new SmsVerifyCodeAuthenticationToken(user, null, authorities);
        authenticationToken.setDetails(authentication.getDetails());
        return authenticationToken;
    }
}

还需重写自己的Token类:public class SmsVerifyCodeAuthenticationToken extends AbstractAuthenticationToken,基本就是对着框架的UsernamePasswordAuthenticationToken疯狂照抄(一模一样),把credentials字段改为存储验证码,外部调用getCredentials返回验证码即可同账号密码验证。

SmsVerifyCodeAuthenticationToken.java

完全就照着UsernamePasswordAuthenticationToken扒的,我都不好意思贴

/**
 * 短信验证码token
 * @author Icy
 * @version 1.0.0
 * @since 1.0.0
 */
public class SmsVerifyCodeAuthenticationToken extends AbstractAuthenticationToken {

    private static final long serialVersionUID = SpringSecurityCoreVersion.SERIAL_VERSION_UID;

    private final Object principal;

    private String verifyCode;

    /**
     * 未登录认证
     * @param principal 认证信息
     */
    public SmsVerifyCodeAuthenticationToken(Object principal, String verifyCode) {
        super(null);
        this.principal = principal;
        this.verifyCode = verifyCode;
        setAuthenticated(false);
    }

    public SmsVerifyCodeAuthenticationToken(Object principal, String verifyCode, Collection<? extends GrantedAuthority> authorities) {
        super(authorities);
        this.principal = principal;
        this.verifyCode = verifyCode;
        super.setAuthenticated(true);
    }

    @Override
    public Object getCredentials() {
        return verifyCode;
    }

    @Override
    public Object getPrincipal() {
        return principal;
    }

    public String getVerifyCode() {
        return verifyCode;
    }

    @Override
    public void setAuthenticated(boolean isAuthenticated) throws IllegalArgumentException {
        Assert.isTrue(!isAuthenticated, "Cannot set this token to trusted - use constructor which takes a GrantedAuthority list instead");
        super.setAuthenticated(false);
    }

    @Override
    public void eraseCredentials() {
        super.eraseCredentials();
        verifyCode = null;
    }
}

  Java知识库 最新文章
计算距离春节还有多长时间
系统开发系列 之WebService(spring框架+ma
springBoot+Cache(自定义有效时间配置)
SpringBoot整合mybatis实现增删改查、分页查
spring教程
SpringBoot+Vue实现美食交流网站的设计与实
虚拟机内存结构以及虚拟机中销毁和新建对象
SpringMVC---原理
小李同学: Java如何按多个字段分组
打印票据--java
上一篇文章      下一篇文章      查看所有文章
加:2022-09-04 00:56:31  更:2022-09-04 00:58:18 
 
开发: C++知识库 Java知识库 JavaScript Python PHP知识库 人工智能 区块链 大数据 移动开发 嵌入式 开发工具 数据结构与算法 开发测试 游戏开发 网络协议 系统运维
教程: HTML教程 CSS教程 JavaScript教程 Go语言教程 JQuery教程 VUE教程 VUE3教程 Bootstrap教程 SQL数据库教程 C语言教程 C++教程 Java教程 Python教程 Python3教程 C#教程
数码: 电脑 笔记本 显卡 显示器 固态硬盘 硬盘 耳机 手机 iphone vivo oppo 小米 华为 单反 装机 图拉丁

360图书馆 购物 三丰科技 阅读网 日历 万年历 2024年11日历 -2024/11/23 13:32:53-

图片自动播放器
↓图片自动播放器↓
TxT小说阅读器
↓语音阅读,小说下载,古典文学↓
一键清除垃圾
↓轻轻一点,清除系统垃圾↓
图片批量下载器
↓批量下载图片,美女图库↓
  网站联系: qq:121756557 email:121756557@qq.com  IT数码