springboot前后端分离项目集成Security框架和JWT完成用户校验

前言

Java流行的安全框架有两种Apache Shiro和Spring Security,其中Shiro对于前后端分离项目不是很友好,最终选用了Spring Security。SpringBoot提供了官方的spring-boot-starter-security,能够方便的集成到SpringBoot项目中,但是企业级的使用上,还是需要稍微改造下,本文实现了如下功能:

匿名用户访问无权限资源时的异常处理

登录用户是否有权限访问资源

集成JWT实现登陆授权访问

token过期处理

自定义用户登陆逻辑

SpringSecurity简介

Spring Security是一个功能强大且高度可定制的身份验证和访问控制框架。它实际上是保护基于sprin的应用程序的标准。

Spring Security是一个框架,侧重于为Java应用程序提供身份验证和授权。与所有Spring项目一样,Spring安全性的真正强大之处在于它可以轻松地扩展以满足定制需求

Spring Security 是针对Spring项目的安全框架,也是Spring Boot底层安全模块默认的技术选型,他可以实现强大的Web安全控制,对于安全控制,我们仅需要引入 spring-boot-starter-security 模块,进行少量的配置,即可实现强大的安全管理

1. 引入必要依赖

<!--spring security-->

<dependency>

  <groupId>org.springframework.boot</groupId>

  <artifactId>spring-boot-starter-security</artifactId>

</dependency>

<!-- redis 缓存操作 -->

<dependency>

  <groupId>org.springframework.boot</groupId>

  <artifactId>spring-boot-starter-data-redis</artifactId>

</dependency>

spring-boot-starter-security用于集成security, spring-boot-starter-data-redis用于实现redis缓存

2. 实现AuthenticationEntryPoint

实现AuthenticationEntryPoint,控制用户无权限时的操作

/**

* 认证失败处理类 返回未授权

*

* @author fanglei

* @Date 2023年7月31日17:54:39

*/

@Component

    public class AuthenticationEntryPointImpl implements AuthenticationEntryPoint, Serializable

{

    private static final long serialVersionUID = -8970718410437077606L;

    @Override

    public void commence(HttpServletRequest request, HttpServletResponse response, AuthenticationException e)

    throws IOException

    {

        int code = HttpStatus.UNAUTHORIZED;

        String msg = StringUtils.format("请求访问:{},认证失败,请联系管理员", request.getRequestURI());

        ServletUtils.renderString(response, JSON.toJSONString(ApiResult.error(code, msg)));

    }

}

3. 实现UserDetailsService

实现了UserDetailsService,判断用户是否存在,完成用户自定义登陆

/**

* 用户验证处理

*

* @author fanglei

*/

@Service

@Slf4j

public class UserDetailsServiceImpl implements UserDetailsService

{

    private static final String isEnable ="1";

    @Autowired

    private SysUserService userService;

    @Override

    public UserDetails loadUserByUsername(String username) throws UsernameNotFoundException

    {

        SysUser user = userService.getBaseMapper().selectOne(new QueryWrapper<SysUser>().eq("user_name", username));

        if (StringUtils.isNull(user))

        {

            log.info("登录用户:{} 不存在.", username);

            throw new BaseException("登录用户:" + username + " 不存在");

        }

        else if (isEnable.equals(user.getIsDel()))

        {

            log.info("登录用户:{} 已被删除.", username);

            throw new BaseException("对不起,您的账号:" + username + " 已被删除");

        }

        else if (isEnable.equals(user.getStatus()))

        {

            log.info("登录用户:{} 已被停用.", username);

            throw new BaseException("对不起,您的账号:" + username + " 已停用");

        }

        return new LoginUser(user.getUserId(), user,null);

    }

}

/**

* @author 方磊

*/

@Data

public class LoginUser implements UserDetails {

    private static final long serialVersionUID = 1L;

    /**

    * 用户ID

    */

    private Long userId;

    /**

    * 租户id

    */

    private Long tenantId;

    /**

    * 用户唯一标识

    */

    private String token;

    /**

    * 登录时间

    */

    private Long loginTime;

    /**

    * 过期时间

    */

    private Long expireTime;

    /**

    * 登录IP地址

    */

    private String ipaddress;

    /**

    * 登录地点

    */

    private String loginLocation;

    /**

    * 权限列表

    */

    private Set<String> permissions;

    /**

    * 用户信息

    */

    private SysUser user;

    public LoginUser(SysUser user, Set<String> permissions)

    {

        this.user = user;

        this.permissions = permissions;

    }

    public LoginUser(Long userId, SysUser user, Set<String> permissions)

    {

        this.userId = userId;

        this.user = user;

        this.permissions = permissions;

    }

    @Override

    public Collection<? extends GrantedAuthority> getAuthorities() {

        return null;

    }

    @JSONField(serialize = false)

    @Override

    public String getPassword()

    {

        return user.getPassword();

    }

    @Override

    public String getUsername()

    {

        return user.getUserName();

    }

    /**

    * 账户是否未过期,过期无法验证

    */

    @JSONField(serialize = false)

    @Override

    public boolean isAccountNonExpired()

    {

        return true;

    }

    /**

    * 指定用户是否解锁,锁定的用户无法进行身份验证

    *

    * @return

    */

    @JSONField(serialize = false)

    @Override

    public boolean isAccountNonLocked()

    {

        return true;

    }

    /**

    * 指示是否已过期的用户的凭据(密码),过期的凭据防止认证

    *

    * @return

    */

    @JSONField(serialize = false)

    @Override

    public boolean isCredentialsNonExpired()

    {

        return true;

    }

    /**

    * 是否可用 ,禁用的用户不能身份验证

    *

    * @return

    */

    @JSONField(serialize = false)

    @Override

    public boolean isEnabled()

    {

        return true;

    }

}

4. 退出登陆处理

退出登陆:删除用户缓存纪录并且发送给前端

/**

* 自定义退出处理类 返回成功

*

* @author fanglei

*/

@Configuration

public class LogoutSuccessHandlerImpl implements LogoutSuccessHandler

{

    @Autowired

    private TokenService tokenService;

    /**

    * 退出处理

    *

    * @return

    */

    @Override

    public void onLogoutSuccess(HttpServletRequest request, HttpServletResponse response, Authentication authentication)

            throws IOException, ServletException

    {

        LoginUser loginUser = tokenService.getLoginUser(request);

        if (StringUtils.isNotNull(loginUser))

        {

            String userName = loginUser.getUsername();

            // 删除用户缓存记录

            tokenService.delLoginUser(loginUser.getToken());

        }

        ServletUtils.renderString(response, JSON.toJSONString(ApiResult.success("退出成功")));

    }

}

5. 配置security配置

/**

* spring security配置

*

* @author fanglei

*/

@EnableGlobalMethodSecurity(prePostEnabled = true, securedEnabled = true)

public class SecurityConfig extends WebSecurityConfigurerAdapter

{

    /**

    * 自定义用户认证逻辑

    */

    @Autowired

    private UserDetailsService userDetailsService;

    /**

    * 认证失败处理类

    */

    @Autowired

    private AuthenticationEntryPointImpl unauthorizedHandler;

    /**

    * 退出处理类

    */

    @Autowired

    private LogoutSuccessHandlerImpl logoutSuccessHandler;

    /**

    * token认证过滤器

    */

    @Autowired

    private JwtAuthenticationTokenFilter authenticationTokenFilter;

    /**

    * 跨域过滤器

    */

    @Autowired

    private CorsFilter corsFilter;

    /**

    * 允许匿名访问的地址

    */

    @Autowired

    private PermitAllUrlProperties permitAllUrl;

    /**

    * 解决 无法直接注入 AuthenticationManager

    *

    * @return

    * @throws Exception

    */

    @Bean

    @Override

    public AuthenticationManager authenticationManagerBean() throws Exception

    {

        return super.authenticationManagerBean();

    }

    /**

    * anyRequest          |  匹配所有请求路径

    * access              |  SpringEl表达式结果为true时可以访问

    * anonymous          |  匿名可以访问

    * denyAll            |  用户不能访问

    * fullyAuthenticated  |  用户完全认证可以访问(非remember-me下自动登录)

    * hasAnyAuthority    |  如果有参数,参数表示权限,则其中任何一个权限可以访问

    * hasAnyRole          |  如果有参数,参数表示角色,则其中任何一个角色可以访问

    * hasAuthority        |  如果有参数,参数表示权限,则其权限可以访问

    * hasIpAddress        |  如果有参数,参数表示IP地址,如果用户IP和参数匹配,则可以访问

    * hasRole            |  如果有参数,参数表示角色,则其角色可以访问

    * permitAll          |  用户可以任意访问

    * rememberMe          |  允许通过remember-me登录的用户访问

    * authenticated      |  用户登录后可访问

    */

    @Override

    protected void configure(HttpSecurity httpSecurity) throws Exception

    {

        // 注解标记允许匿名访问的url

        ExpressionUrlAuthorizationConfigurer<HttpSecurity>.ExpressionInterceptUrlRegistry registry = httpSecurity.authorizeRequests();

        permitAllUrl.getUrls().forEach(url -> registry.antMatchers(url).permitAll());

        httpSecurity

                // CSRF禁用,因为不使用session

                .csrf().disable()

                // 禁用HTTP响应标头

                .headers().cacheControl().disable().and()

                // 认证失败处理类

                .exceptionHandling().authenticationEntryPoint(unauthorizedHandler).and()

                // 基于token,所以不需要session

                .sessionManagement().sessionCreationPolicy(SessionCreationPolicy.STATELESS).and()

                // 过滤请求

                .authorizeRequests()

                // 对于登录login 注册register 验证码captchaImage 允许匿名访问

                .antMatchers("/login", "/register", "/captchaImage").permitAll()

                // 除上面外的所有请求全部需要鉴权认证

                .anyRequest().authenticated()

                .and()

                .headers().frameOptions().disable();

        // 添加Logout filter

        httpSecurity.logout().logoutUrl("/logout").logoutSuccessHandler(logoutSuccessHandler);

        // 添加JWT filter

        httpSecurity.addFilterBefore(authenticationTokenFilter, UsernamePasswordAuthenticationFilter.class);

        // 添加CORS filter

        httpSecurity.addFilterBefore(corsFilter, JwtAuthenticationTokenFilter.class);

        httpSecurity.addFilterBefore(corsFilter, LogoutFilter.class);

    }

    /**

    * 强散列哈希加密实现

    */

    @Bean

    public BCryptPasswordEncoder bCryptPasswordEncoder()

    {

        return new BCryptPasswordEncoder();

    }

    /**

    * 身份认证接口

    */

    @Override

    protected void configure(AuthenticationManagerBuilder auth) throws Exception

    {

        auth.userDetailsService(userDetailsService).passwordEncoder(bCryptPasswordEncoder());

    }

}

6. 生成token

    /**

    * 创建令牌

    *

    * @param loginUser 用户信息

    * @return 令牌

    */

    public String createToken(LoginUser loginUser)

    {

        String token = IdUtils.fastUUID();

        loginUser.setToken(token);

        setUserAgent(loginUser);

        refreshToken(loginUser);

        Map<String, Object> claims = new HashMap<>();

        claims.put(Constants.LOGIN_USER_KEY, token);

        return createToken(claims);

    }

    /**

    * 从数据声明生成令牌

    *

    * @param claims 数据声明

    * @return 令牌

    */

    private String createToken(Map<String, Object> claims)

    {

        String token = Jwts.builder()

                .setClaims(claims)

                .signWith(SignatureAlgorithm.HS512, secret).compact();

        return token;

    }

    /**

    * 验证令牌有效期,相差不足20分钟,自动刷新缓存

    *

    * @param loginUser

    * @return 令牌

    */

    public void verifyToken(LoginUser loginUser)

    {

        long expireTime = loginUser.getExpireTime();

        long currentTime = System.currentTimeMillis();

        if (expireTime - currentTime <= MILLIS_MINUTE_TEN)

        {

            refreshToken(loginUser);

        }

    }

    /**

    * 刷新令牌有效期

    *

    * @param loginUser 登录信息

    */

    public void refreshToken(LoginUser loginUser)

    {

        loginUser.setLoginTime(System.currentTimeMillis());

        loginUser.setExpireTime(loginUser.getLoginTime() + expireTime * MILLIS_MINUTE);

        // 根据uuid将loginUser缓存

        String userKey = getTokenKey(loginUser.getToken());

        redisCache.setCacheObject(userKey, loginUser, expireTime, TimeUnit.MINUTES);

    }

7. 实现用户登录接口

/**

* @author 方磊

*/

@RequestMapping("/api/v1")

@RestController

public class LoginController {

    @Autowired

    private TokenService tokenService;

    @Autowired

    private SysUserService userService;


    @GetMapping("/login")

    public ApiResult login(String userName , String password){

        LoginUser loginUser = sysService.queryUserByUserNmae(userName);

        retrun ApiResult.success(tokenService.createToken(loginUser));

    }


}

8. 总结

自此security简单集成完成 基础项目可简单使用 可以使用token进行验证 权限模块完善中.....

©著作权归作者所有,转载或内容合作请联系作者
  • 序言:七十年代末,一起剥皮案震惊了整个滨河市,随后出现的几起案子,更是在滨河造成了极大的恐慌,老刑警刘岩,带你破解...
    沈念sama阅读 206,013评论 6 481
  • 序言:滨河连续发生了三起死亡事件,死亡现场离奇诡异,居然都是意外死亡,警方通过查阅死者的电脑和手机,发现死者居然都...
    沈念sama阅读 88,205评论 2 382
  • 文/潘晓璐 我一进店门,熙熙楼的掌柜王于贵愁眉苦脸地迎上来,“玉大人,你说我怎么就摊上这事。” “怎么了?”我有些...
    开封第一讲书人阅读 152,370评论 0 342
  • 文/不坏的土叔 我叫张陵,是天一观的道长。 经常有香客问我,道长,这世上最难降的妖魔是什么? 我笑而不...
    开封第一讲书人阅读 55,168评论 1 278
  • 正文 为了忘掉前任,我火速办了婚礼,结果婚礼上,老公的妹妹穿的比我还像新娘。我一直安慰自己,他们只是感情好,可当我...
    茶点故事阅读 64,153评论 5 371
  • 文/花漫 我一把揭开白布。 她就那样静静地躺着,像睡着了一般。 火红的嫁衣衬着肌肤如雪。 梳的纹丝不乱的头发上,一...
    开封第一讲书人阅读 48,954评论 1 283
  • 那天,我揣着相机与录音,去河边找鬼。 笑死,一个胖子当着我的面吹牛,可吹牛的内容都是我干的。 我是一名探鬼主播,决...
    沈念sama阅读 38,271评论 3 399
  • 文/苍兰香墨 我猛地睁开眼,长吁一口气:“原来是场噩梦啊……” “哼!你这毒妇竟也来了?” 一声冷哼从身侧响起,我...
    开封第一讲书人阅读 36,916评论 0 259
  • 序言:老挝万荣一对情侣失踪,失踪者是张志新(化名)和其女友刘颖,没想到半个月后,有当地人在树林里发现了一具尸体,经...
    沈念sama阅读 43,382评论 1 300
  • 正文 独居荒郊野岭守林人离奇死亡,尸身上长有42处带血的脓包…… 初始之章·张勋 以下内容为张勋视角 年9月15日...
    茶点故事阅读 35,877评论 2 323
  • 正文 我和宋清朗相恋三年,在试婚纱的时候发现自己被绿了。 大学时的朋友给我发了我未婚夫和他白月光在一起吃饭的照片。...
    茶点故事阅读 37,989评论 1 333
  • 序言:一个原本活蹦乱跳的男人离奇死亡,死状恐怖,灵堂内的尸体忽然破棺而出,到底是诈尸还是另有隐情,我是刑警宁泽,带...
    沈念sama阅读 33,624评论 4 322
  • 正文 年R本政府宣布,位于F岛的核电站,受9级特大地震影响,放射性物质发生泄漏。R本人自食恶果不足惜,却给世界环境...
    茶点故事阅读 39,209评论 3 307
  • 文/蒙蒙 一、第九天 我趴在偏房一处隐蔽的房顶上张望。 院中可真热闹,春花似锦、人声如沸。这庄子的主人今日做“春日...
    开封第一讲书人阅读 30,199评论 0 19
  • 文/苍兰香墨 我抬头看了看天上的太阳。三九已至,却和暖如春,着一层夹袄步出监牢的瞬间,已是汗流浃背。 一阵脚步声响...
    开封第一讲书人阅读 31,418评论 1 260
  • 我被黑心中介骗来泰国打工, 没想到刚下飞机就差点儿被人妖公主榨干…… 1. 我叫王不留,地道东北人。 一个月前我还...
    沈念sama阅读 45,401评论 2 352
  • 正文 我出身青楼,却偏偏与公主长得像,于是被迫代替她去往敌国和亲。 传闻我的和亲对象是个残疾皇子,可洞房花烛夜当晚...
    茶点故事阅读 42,700评论 2 345

推荐阅读更多精彩内容