使用Spring Security框架处理认证和授权

Spring Security框架

Spring Security主要解决了认证与授权相关的问题。

认证:判断某个账号是否允许访问某个系统,简单来说,就是验证登录

授权:判断是否允许已经通过认证的账号访问某个资源,简单来说,就是判断是否具有权限执行某项操作

1.认证

第一步,添加依赖

<!-- Spring Boot Security依赖项,用于处理认证与授权相关的问题 -->
<dependency>
    <groupId>org.springframework.boot</groupId>
    <artifactId>spring-boot-starter-security</artifactId>
</dependency>

添加以上依赖项后,项目会发生以下变化,下面三点不重要,知道就行(Spring Boot中的Spring Security的默认行为):

  • 所有的请求都是必须要登录才允许访问的,包括错误的URL

  • 提供了默认的登录页面,当未登录时,会自动重定向到此登录页面

  • 提供了临时的登录账号,用户名是user,密码是启动项目时在控制台中的UUID值(每次重启项目都会不同)

第二步,创建配置类,继承自WebSecurityConfigurerAdapter类,在上添加@Configuration注解,不要使用super调用父类的此方法,自己配置

@Configuration
@EnableGlobalMethodSecurity(prePostEnabled = true)
public class SecurityConfiguration extends WebSecurityConfigurerAdapter {



    // 新增
    @Autowired
    private JwtAuthorizationFilter jwtAuthorizationFilter;

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

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

    @Override
    protected void configure(HttpSecurity http) throws Exception {

        // 处理“需要通过认证,但是实际上未通过认证就发起的请求”导致的错误
        http.exceptionHandling().authenticationEntryPoint(new AuthenticationEntryPoint() {
            @Override
            public void commence(HttpServletRequest request, HttpServletResponse response, AuthenticationException e) throws IOException, ServletException {
                String message = "未检测到登录,请登录!(在开发阶段,看到此提示时,请检查客户端是否携带了JWT向服务器端发起请求)";
                JsonResult<Void> jsonResult = JsonResult.fail(ServiceCode.ERR_UNAUTHORIZED, message);
                response.setContentType("application/json; charset=utf-8");
                PrintWriter printWriter = response.getWriter();
                printWriter.println(JSON.toJSONString(jsonResult));
                printWriter.close();
            }
        });


        
        http.csrf().disable();
        // 白名单
        // 使用1个星号,表示通配此层级的任意资源,例如:/admin/*,可以匹配 /admin/delete、/admin/add-new
        // 但是,不可以匹配多个层级,例如:/admin/*,不可以匹配 /admin/9527/delete
        // 使用2个连续的星号,表示通配任何层级的任意资源,例如:/admin/**,可以匹配 /admin/delete、/admin/9527/delete
        String[] urls = {
                "/doc.html",
                "/**/*.js",
                "/**/*.css",
                "/swagger-resources",
                "/v2/api-docs",
                "/admin/login"
        };


        http.cors();

//        http.authorizeRequests() // 配置URL的访问控制
//
//                // 重要,以下2行代码表示:对所有OPTIONS类型的请求“放行”
//                .mvcMatchers(HttpMethod.OPTIONS, "/**")
//                .permitAll()
//
//                .mvcMatchers(urls)
//                .permitAll()
//                .anyRequest()
//                .authenticated();

        // 配置URL的访问控制
        http.authorizeRequests() // 配置URL的访问控制
                .mvcMatchers(urls) // 匹配某些URL
                .permitAll() // 直接许可,即:不需要通过认证就可以直接访问
                .anyRequest() // 任何请求
                .authenticated(); // 以上配置的请求需要是通过认证的

        http.addFilterBefore(jwtAuthorizationFilter,
                UsernamePasswordAuthenticationFilter.class);
    }
}

1.在configure方法中对URL的访问控制配置

2.在configure方法中禁用“防止伪造的跨域攻击”这种防御机制,因为在“前后端分离”的项目中,并不适用

3.配置PasswordEncoder对象到Spring容器中,因为Spring Security框架在处理认证时,会自动使用PasswordEncoder中的matches()方法将原文和密文进行对比,并且自带BCryptPasswordEncoder

4.在Spring Security的配置类中重写authenticationManagerBean()方法,并在此方法上添加@Bean注解,则Spring会自动调用此方法,得到AuthenticationManager类型的对象,并保存在Spring容器中,后续,在处理登录功能时需要AuthenticationManager时可以自动装配!

5.开启“基于方法的权限检查”,通过注解配置权限在控制器类中

6.处理未登录的错误

7.复杂请求的预检机制导致的跨域问题,Spring Security的配置类中,要么对所有OPTIONS类型的请求“放行”,要么,通过http.cors();

第三步,Spring Security框架处理认证需要自定义类,实现UserDetailsService接口,并保证此类是组件类就是添加@Service注解

Spring Security框架会基于此实现类来处理认证

并且会自动使用登录表单提交过来的用户名来调用以上loadUserByUsername()方法,并得到UserDetails类型的对象,此对象中应该包含用户的相关信息,例如密码、账号状态等,接下来,Spring Security会自动使用登录表单提交过来的密码与UserDetails中的密码进行对比,且判断账号状态,以决定此账号是否能够通过认证。

认证结果与loadUserByUsername的返回值有关,会被框架自动对比

@Service
@Slf4j
public class UserDetailService implements UserDetailsService {

    @Autowired
    private AdminMapper adminMapper;

    @Override
    public UserDetails loadUserByUsername(String s) throws UsernameNotFoundException {
        log.debug("Spring Security调用了loadUserByUsername()方法,参数:{}", s);

        AdminLoginInfoVO loginInfo = adminMapper.getLoginInfoByUsername(s);
        log.debug("从数据库查询用户名【{}】匹配的信息,结果:{}", s, loginInfo);

        if (loginInfo == null) {
            return null; // 暂时
        }

        // ========== 重要 ===========
        List<String> permissions = loginInfo.getPermissions();
        List<SimpleGrantedAuthority> authorities = new ArrayList<>();
        for (String permission : permissions) {
            SimpleGrantedAuthority authority = new SimpleGrantedAuthority(permission);
            authorities.add(authority);
        }

        AdminDetails adminDetails = new AdminDetails(loginInfo.getId(),
                loginInfo.getUsername(),
                loginInfo.getPassword(),
                loginInfo.getEnable() == 1,
                authorities);
        log.debug("即将向Spring Security返回UserDetails对象:{}", adminDetails);
        return adminDetails;
    }
}

**提示:**当项目中存在UserDetailsService类型的组件对象时,Spring Security框架不再提供临时的账号(用户名为user密码为启动项目时的UUID值的账号)!

**注意:**Spring Security在处理认证时,要求密码必须经过加密码处理,即使你执意不加密,也必须明确的表示出来,在配置类中配置!

第四步,接受登录请求要写控制器类和service层

在service层中有两个操作,

在service层中需要自动装配AuthenticationManager

处理认证时,当通过认证,需要获取返回结果,并且,将返回结果存入到SecurityContext

SecurityContext默认是基于Session的,也就是将Session存入,并且要返回jwt给客户端,以便于客户端进行登陆后的操作,登陆后的操作会在请求中携带jwt(Session和jwt是干什么的,自己搜,懒得讲)

1.添加依赖

<dependency>
    <groupId>io.jsonwebtoken</groupId>
    <artifactId>jjwt</artifactId>
    <version>0.9.1</version>
</dependency>

2.在service中生成并返回jwt

@Service
@Slf4j
public class AdminServiceImpl implements IAdminService{

    @Autowired
    private AuthenticationManager authenticationManager;


    @Value("${csmall.jwt.secret-key}")
    private String secretKey;

    @Value("${csmall.jwt.duration-in-minute}")
    private Integer durationInMinute;

    @Override
    public String login(AdminLoginDTO adminLoginDTO) {
        log.debug("开始处理【管理员登录】的业务,参数:{}", adminLoginDTO);
        // 执行认证
        Authentication authentication = new UsernamePasswordAuthenticationToken(
                adminLoginDTO.getUsername(), adminLoginDTO.getPassword());
        Authentication authenticationResult
                = authenticationManager.authenticate(authentication);
        log.debug("认证通过,认证结果:{}", authenticationResult);
        log.debug("认证通过,认证结果中的当事人:{}", authenticationResult.getPrincipal());

        // =========== 新增以下代码 ==========
        // 将通过认证的管理员的相关信息存入到JWT中
        // 准备生成JWT的相关数据

        Date date = new Date(System.currentTimeMillis() + durationInMinute * 60 * 1000);
        AdminDetails principal = (AdminDetails) authenticationResult.getPrincipal();
        Map<String, Object> claims = new HashMap<>();
        claims.put("id", principal.getId());
        claims.put("username", principal.getUsername());
        Collection<GrantedAuthority> authorities = principal.getAuthorities(); // 新增
        String authoritiesJsonString = JSON.toJSONString(authorities); // 新增
        claims.put("authoritiesJsonString", authoritiesJsonString); // 新增
        // 生成JWT
        String jwt = Jwts.builder()
                // Header
                .setHeaderParam("alg", "HS256")
                .setHeaderParam("typ", "JWT")
                // Payload
                .setClaims(claims)
                // Signature
                .setExpiration(date)
                .signWith(SignatureAlgorithm.HS256, secretKey)
                // 完成
                .compact();
        // 返回JWT
        return jwt;
    }
}

第五步,登录后的请求需要解析jwt

根包下创建过滤器类,继承自OncePerRequestFilter,并在类上添加组件注解@Component

解析JWT时的异常,需要被捕捉

过滤器是整个服务器端中最早接收到所有请求的组件,此时,控制器等其它组件尚未运行,则不可以使用此前的“全局异常处理器”来处理解析JWT时的异常(全局异常处理器只能处理控制器抛出的异常)

响应到客户端的结果,仍推荐使用JsonResult封装相关信息,而响应到客户端的结果应该是JSON格式的,则需要将JsonResult对象转换成JSON格式的字符串!

需要添加fastjson依赖项

<dependency>
    <groupId>com.alibaba</groupId>
    <artifactId>fastjson</artifactId>
    <version>1.2.75</version>
</dependency>
@Slf4j
@Component
public class JwtAuthorizationFilter extends OncePerRequestFilter {

    @Value("${csmall.jwt.secret-key}")
    private String secretKey;

    @Override
    protected void doFilterInternal(HttpServletRequest request, HttpServletResponse response, FilterChain filterChain) throws ServletException, IOException {

        // 清除SecurityContext中的数据
        SecurityContextHolder.clearContext();

        // 根据业内惯用的做法,客户端应该将JWT保存在请求头(Request Header)中的名为Authorization的属性中
        String jwt = request.getHeader("Authorization");
        log.debug("尝试接收客户端携带的JWT数据,JWT:{}", jwt);

        // 判断客户端是否提交了有效的JWT
        if (!StringUtils.hasText(jwt) || jwt.length() < 113) {
            // 直接放行
            filterChain.doFilter(request, response);
            // 【重要】终止当前方法的执行,不执行当前方法接下来的代码
            return;
        }


        Claims claims = null;
        response.setContentType("application/json; charset=utf-8");
        try {
            claims = Jwts.parser()
                    .setSigningKey(secretKey)
                    .parseClaimsJws(jwt)
                    .getBody();
        } catch (SignatureException e) {
            String message = "非法访问!";
            log.warn("解析JWT时出现SignatureException,响应消息:{}", message);
            JsonResult<Void> jsonResult
                    = JsonResult.fail(ServiceCode.ERR_JWT_SIGNATURE, message);
            PrintWriter printWriter = response.getWriter();
            printWriter.println(JSON.toJSONString(jsonResult));
            printWriter.close();
            return;
        } catch (MalformedJwtException e) {
            String message = "非法访问!";
            log.warn("解析JWT时出现MalformedJwtException,响应消息:{}", message);
            JsonResult<Void> jsonResult
                    = JsonResult.fail(ServiceCode.ERR_JWT_MALFORMED, message);
            PrintWriter printWriter = response.getWriter();
            printWriter.println(JSON.toJSONString(jsonResult));
            printWriter.close();
            return;
        } catch (ExpiredJwtException e) {
            String message = "您的登录信息已过期,请重新登录!";
            log.warn("解析JWT时出现ExpiredJwtException,响应消息:{}", message);
            JsonResult<Void> jsonResult
                    = JsonResult.fail(ServiceCode.ERR_JWT_EXPIRED, message);
            PrintWriter printWriter = response.getWriter();
            printWriter.println(JSON.toJSONString(jsonResult));
            printWriter.close();
            return;
        }

        Long id = claims.get("id", Long.class);
        String username = claims.get("username", String.class);
        String authoritiesJsonString = claims.get("authoritiesJsonString", String.class);
        log.debug("从JWT中解析得到的管理员ID:{}", id);
        log.debug("从JWT中解析得到的管理员用户名:{}", username);
        log.debug("从JWT中解析得到的管理员权限列表JSON:{}", authoritiesJsonString);

        // 将JSON格式的权限列表转换成Authentication需要的类型(Collection<GrantedAuthority>)
        List<SimpleGrantedAuthority> authorities =
                JSON.parseArray(authoritiesJsonString, SimpleGrantedAuthority.class);

        // 基于解析JWT的结果创建认证信息
        LoginPrincipal principal = new LoginPrincipal();
        principal.setId(id);
        principal.setUsername(username);
        Object credentials = null; // 应该为null
        Authentication authentication = new UsernamePasswordAuthenticationToken(
                principal, credentials, authorities);

        // 将认证信息存入到SecurityContext中
        SecurityContext securityContext = SecurityContextHolder.getContext();
        securityContext.setAuthentication(authentication);

        // 过滤器链继承向后执行,即:放行
        // 如果没有执行以下代码,表示“阻止”,即此请求的处理过程到此结束,在浏览器中将显示一片空白
        filterChain.doFilter(request, response);
    }

}

上述代码中secretKey和duration-in-minute定义在配置文件(application.yml系列文件)中,以便于统一管理此值,并且,客户可以修改此值(如果定义在.java文件中,经过编译后,此值将无法修改)。

在访求处理过程中(特殊业务下),可能需要获取“当事人”的ID、用户名等数据

1.自定义数据类型表示“当事人”

2.在控制器中处理请求的方法的参数列表中,可以通过@AuthenticationPrincipal注入LoginPrincipal类型的参数,在处理请求的过程中,可以通过此参数获取当事人的具体数据

Security存在的异常,需要被全局异常处理器捕捉

用户名不存在

org.springframework.security.authentication.InternalAuthenticationServiceException: UserDetailsService returned null, which is an interface contract violation

密码错误

org.springframework.security.authentication.BadCredentialsException: 用户名或密码错误

账号被禁用

org.springframework.security.authentication.DisabledException: 用户已失效

无此权限的账号提交请求

org.springframework.security.access.AccessDeniedException: 不允许访问

2.授权

在登录成功后,将此登陆者的权限列表也写入到JWT中,在上述过滤器中的代码可以看到

在控制器类中的方法上添加注解就可以配置权限

@PreAuthorize("hasAuthority('/ams/admin/read')") // 配置权限

单点登录

指的是有两个服务端

第一个负责登录,第二个负责登陆后的请求

在第二个服务端有以下几步

  • 添加依赖
    • spring-boot-starter-security
    • jjwt
    • fastjson
  • 复制配置文件中关于JWT的自定义配置
  • 更新ServiceCode中的枚举值
  • 更新GlobalExceptionHandler中处理异常的方法
  • LoginPrincipal
  • JwtAuthorizationFilter
  • SecurityConfiguration
    • 删除PasswordEncoder对应的@Bean方法
    • 删除AuthenticationManager对应的@Bean方法
    • 删除“白名单”中的/admins/login路径

题外话:使用axios携带JWT发起请求

当需要携带JWT发起请求时,根据业内惯例,JWT应该放在请求头的Authorization属性中,则使用axios时需要自定义请求头

// 以下调用的create()表示创建一个axios实例,此函数的参数是一个对象,用于表示新创建的axios的参数

this.axios
    .create({'headers': {'Authorization': localStorage.getItem('jwt')}})
    .post()// 与此前使用相同,在此处调用get()或post()发起请求

其实也不难,知道自己要做什么就行了,就是这么简单

评论 1
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包

打赏作者

狂铁不狂

你的鼓励将是我创作的最大动力

¥1 ¥2 ¥4 ¥6 ¥10 ¥20
扫码支付:¥1
获取中
扫码支付

您的余额不足,请更换扫码支付或充值

打赏作者

实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值