认证

信息安全中认证包含多种方式,比如最基础也是最常用的用户名密码方式、更为复杂的证书方式等。实际开发中,99%的项目都采用用户名和密码认证,SpringSecurity默认的过滤器链提供了一个丑陋的登录页和登出接口,实现了前后端不分离方式的密码认证,并默认使用Tomcat容器的Session存储登录状态。然而这种方式可能不符合我们的要求,现在一般采用前后端分离方式开发,我们需要提供登入和登出的JSON接口而不是页面,虽然SpringSecurity默认不支持这种方式,但我们可以通过扩展实现这些功能。

鉴于SpringSecurity的API设计的比较混乱,这篇笔记我们主要以例子的形式,学习SpringSecurity中实现认证的一些最佳实践。

认证相关扩展点

SpringSecurity过滤器链中,默认接收登录信息的组件是UsernamePasswordAuthenticationFilter,但使用Filter接收登陆参数着实不是个好的实现方式,建议将其禁用并自定义Controller实现登录接口,不过Controller内部代码流程和UsernamePasswordAuthenticationFilter类似,编写Controller时我们也可以参考Filter中的这部分代码,下图以自定义登录Controller场景为例介绍认证的核心流程。

AuthenticationManager:该组件是对认证逻辑的抽象,在需要认证时,我们要调用authenticationManager.authenticate(authenticationToken)方法来判断是否认证成功。该组件的默认实现是ProviderManager,它又是一层抽象封装,支持根据不同的认证类型选择不同的AuthenticationProvider,并调用AuthenticationProviderauthenticate()方法。例如对于比较常用的用户名密码认证方式会默认选择DaoAuthenticationProvider,该Provider底层又会调用UserDetailsService查询用户信息。实际开发中,如果有对认证方式进行扩展的需求,我们一般会扩展AuthenticationProvider

Authentication:认证信息的抽象,可以理解为包含用户名、密码、权限的对象,我们可以将其设置到SecurityContext中以保存认证状态。

AuthenticationTokenAuthenticationTokenAuthentication的子接口,但该接口是代表提交上来待比对的认证身份信息的抽象,SpringSecurity支持很多种不同类型的AuthenticationToken,其中比较常用的是UsernamePasswordAuthenticationToken,能够满足大多数需求。极特殊情况下,我们也可以根据需求自定义AuthenticationToken实现特定的认证功能。

UserDetailsService:该组件是用户信息来源的抽象,以Bean的形式注册到Spring容器中,可以理解为从数据源中查询用户信息的服务。该组件的默认实现是InMemoryUserDetailsManager,一般不符合我们的要求,我们需要扩展该类实现从数据表中查询用户并交给AuthenticationManager执行认证判断逻辑。

至于已经登陆后再访问其它接口的流程,其实我们在前一篇已经介绍过了。登录接口登录成功后会调用securityContext.setAuthentication()设置认证信息,再次访问其它接口时首先SecurityContextPersistenceFilter会从Session中加载SecurityContext,而FilterSecurityInterceptor会对SecurityContext进行判断并做出相应处理。登出则会清除SecurityContext,也就清除了登录状态。

实现用户名密码认证

SpringSecurity配置

下面代码中我们创建了JavaConfig配置类用于配置SpringSecurity框架。

package com.gacfox.demo.demosecurity.security;

import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.security.authentication.AuthenticationManager;
import org.springframework.security.config.annotation.authentication.builders.AuthenticationManagerBuilder;
import org.springframework.security.config.annotation.web.builders.HttpSecurity;
import org.springframework.security.crypto.bcrypt.BCryptPasswordEncoder;
import org.springframework.security.crypto.password.PasswordEncoder;
import org.springframework.security.web.SecurityFilterChain;

@Configuration
public class SecurityConfiguration {
    @Bean
    public SecurityFilterChain securityFilterChain(HttpSecurity httpSecurity) throws Exception {
        httpSecurity
                // 关闭CSRF保护机制(生产环境根据实际情况配置,不可无脑禁用)
                .csrf().disable()
                // 关闭HTTP Basic认证(用不到)
                .httpBasic().disable()
                // 关闭表单认证(自己实现认证接口,用不到)
                .formLogin().disable()
                // 关闭RequestCacheAwareFilter(前后端分离项目,用不到,自行用Controller实现登录)
                .requestCache().disable()
                // 关闭LogoutFilter(前后端分离项目,用不到,自行用Controller实现登出)
                .logout().disable()
                // 路径配置/login允许匿名访问,其它均需要登录
                .authorizeRequests()
                .antMatchers("/login").anonymous()
                .anyRequest().authenticated();
        return httpSecurity.build();
    }

    @Bean
    public AuthenticationManager authenticationManager(HttpSecurity httpSecurity) throws Exception {
        return httpSecurity.getSharedObject(AuthenticationManagerBuilder.class).build();
    }

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

HttpSecurity配置中,我们关闭了CSRF,这是出于演示方便考虑,实际开发中切勿直接关闭;我们关闭了HTTP Basic认证,我们极少使用这种认证方式;我们关闭了表单认证,因为我们要自定义Controller接口进行登录,这会直接禁用UsernamePasswordAuthenticationFilter,同时也会禁用自带的那个丑陋的登陆页面;我们关闭了Request Cache,前后端分离项目中不需要服务端缓存重定向来源;我们关闭了Logout功能,因为我们会自定义Controller接口实现登出,这会禁用LogoutFilter。此外我们配置了/login接口允许匿名访问,而其它接口必须认证。

除了HttpSecurity,我们还注册了BCryptPasswordEncoder()这个Bean,它是一个密码的散列值计算组件,底层算法使用BCrypt。推荐使用该算法,而不要自作聪明的使用MD5等,有关安全算法内容可以参考信息安全相关章节。

此外我们还注册了AuthenticationManager,它的默认实现是ProviderManager,对于简单的密码认证使用该实现足够了,但如果要实现更复杂的认证方式比如多因素认证等,则需要自定义AuthenticationManager。

相关实体类

AdminUser.java是我们自定义的实体类,对应数据库中的用户表,它包含主键、用户名和密码字段。

package com.gacfox.demo.demosecurity.model;

import lombok.AllArgsConstructor;
import lombok.Builder;
import lombok.Data;
import lombok.NoArgsConstructor;

import javax.persistence.*;
import java.io.Serializable;

@Data
@Builder
@AllArgsConstructor
@NoArgsConstructor
@Entity
@Table(name = "t_admin_user")
public class AdminUser implements Serializable {
    @Id
    @GeneratedValue(strategy = GenerationType.IDENTITY)
    @Column(name = "user_id")
    private Long userId;
    @Column(name = "username")
    private String username;
    @Column(name = "password")
    private String password;
}

AdminUserDetails.java实现了UserDetails接口,该接口是SpringSecurity中UserDetailsService使用的用户信息实体类抽象,实际开发中我们可以将其作为用户实体类的包装类供SpringSecurity使用。

package com.gacfox.demo.demosecurity.security;

import com.gacfox.demo.demosecurity.model.AdminUser;
import lombok.AllArgsConstructor;
import lombok.Data;
import lombok.NoArgsConstructor;
import org.springframework.security.core.GrantedAuthority;
import org.springframework.security.core.userdetails.UserDetails;

import java.util.Collection;

@Data
@AllArgsConstructor
@NoArgsConstructor
public class AdminUserDetails implements UserDetails {
    private AdminUser adminUser;

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

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

    @Override
    public String getUsername() {
        return adminUser.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;
    }
}

注意这里authorities是授权相关的功能,我们这里还没有具体实现授权,因此返回了null。此外AdminUserDetails还支持账号过期、冻结等功能,实际开发中我们需要根据我们的需求来实现。

扩展UserDetailsService

前面介绍过UserDetailsService用于从数据源获取用户信息,这里我们自定义AdminUserDetailsService实现该接口,用来从数据库中查询用户信息,并封装为UserDetails

package com.gacfox.demo.demosecurity.security;

import com.gacfox.demo.demosecurity.model.AdminUser;
import com.gacfox.demo.demosecurity.repository.AdminUserRepository;
import org.springframework.security.core.userdetails.UserDetails;
import org.springframework.security.core.userdetails.UserDetailsService;
import org.springframework.security.core.userdetails.UsernameNotFoundException;
import org.springframework.stereotype.Service;

import javax.annotation.Resource;

@Service
public class AdminUserDetailsService implements UserDetailsService {
    @Resource
    private AdminUserRepository adminUserRepository;

    @Override
    public UserDetails loadUserByUsername(String username) throws UsernameNotFoundException {
        AdminUser adminUser = adminUserRepository.findByUsername(username);
        if (adminUser == null) {
            throw new UsernameNotFoundException(username);
        }
        return new AdminUserDetails(adminUser);
    }
}

这里操作数据库我们使用的框架是SpringDataJPA,有关这个ORM框架的使用方式可以参考SpringData相关章节,这里就不多介绍了。

Controller实现登入登出接口

UsernamePasswordAuthenticationFilterLogoutFilter已经被我们禁用了,我们还需要两个接口实现登入和登出。下面代码中我们使用Controller实现了登入和登出接口,以及一个查询用户信息接口,它们适用于前后端分离工程。

package com.gacfox.demo.demosecurity.controller;

import com.gacfox.demo.demosecurity.model.AdminUser;
import com.gacfox.demo.demosecurity.model.ApiResult;
import com.gacfox.demo.demosecurity.model.vo.AdminUserVo;
import com.gacfox.demo.demosecurity.model.vo.LoginFormBean;
import com.gacfox.demo.demosecurity.security.AdminUserDetails;
import org.springframework.beans.BeanUtils;
import org.springframework.security.authentication.AuthenticationManager;
import org.springframework.security.authentication.UsernamePasswordAuthenticationToken;
import org.springframework.security.core.Authentication;
import org.springframework.security.core.AuthenticationException;
import org.springframework.security.core.context.SecurityContextHolder;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.PostMapping;
import org.springframework.web.bind.annotation.RequestBody;
import org.springframework.web.bind.annotation.RestController;

import javax.annotation.Resource;

@RestController
public class LoginController {
    @Resource
    AuthenticationManager authenticationManager;

    @PostMapping("/login")
    public ApiResult<?> login(@RequestBody LoginFormBean loginFormBean) {
        UsernamePasswordAuthenticationToken token = new UsernamePasswordAuthenticationToken(loginFormBean.getUsername(), loginFormBean.getPassword());
        try {
            Authentication authentication = authenticationManager.authenticate(token);
            SecurityContextHolder.getContext().setAuthentication(authentication);
            return ApiResult.success("登录成功");
        } catch (AuthenticationException e) {
            return ApiResult.failure("用户名或密码错误");
        }
    }

    @GetMapping("/userInfo")
    public ApiResult<?> getAdminUserInfo() {
        Authentication authentication = SecurityContextHolder.getContext().getAuthentication();
        AdminUser adminUser = ((AdminUserDetails) authentication.getPrincipal()).getAdminUser();
        AdminUserVo adminUserVo = new AdminUserVo();
        BeanUtils.copyProperties(adminUser, adminUserVo);
        return ApiResult.success(adminUserVo);
    }

    @GetMapping("/logout")
    public ApiResult<?> logout() {
        SecurityContextHolder.clearContext();
        return ApiResult.success("成功");
    }
}

代码其实很简单,认证就是调用authenticationManager.authenticate()接口,AuthenticationManager底层比对用户名和密码,如果出错会抛出相应异常,如果一切正常就会返回Authentication实例,我们将其设置到SecurityContext即可,SpringSecurity的SecurityContextPersistenceFilter会帮我们将SecurityContext写入Session。

登出更加简单,调用SecurityContextHolder.clearContext(),登录状态也就被清除了。

扩展AuthenticationProvider

实际开发中可能遇到认证字段不只有用户名密码的情况,比如需要通过用户名、密码和验证码来认证,或者需要用户名、密码、验证码再加上手机号来认证,甚至更多的认证因素。认证规则可能也比较灵活,例如7天内登录不需要校验手机号验证码,但超过7天未登录就需要校验手机号验证码。SpringSecurity中AuthenticationProvider具体实现了认证的代码逻辑,上述这些功能都可以通过扩展AuthenticationProvider来实现。

下面例子代码基于上面的例子工程,代码中,我们自定义AuthenticationProvider实现了用户名、密码和验证码登录。

package com.gacfox.demo.demosecurity.security;

import org.springframework.security.authentication.AuthenticationProvider;
import org.springframework.security.authentication.BadCredentialsException;
import org.springframework.security.authentication.UsernamePasswordAuthenticationToken;
import org.springframework.security.core.Authentication;
import org.springframework.security.core.AuthenticationException;
import org.springframework.security.core.userdetails.UserDetails;
import org.springframework.security.core.userdetails.UserDetailsService;
import org.springframework.security.core.userdetails.UsernameNotFoundException;
import org.springframework.security.crypto.password.PasswordEncoder;
import org.springframework.stereotype.Component;
import org.springframework.web.context.request.RequestContextHolder;
import org.springframework.web.context.request.ServletRequestAttributes;

import javax.annotation.Resource;
import javax.servlet.http.HttpServletRequest;

@Component
public class LoginAuthenticationProvider implements AuthenticationProvider {
    @Resource
    private UserDetailsService userDetailsService;
    @Resource
    private PasswordEncoder passwordEncoder;

    @Override
    public Authentication authenticate(Authentication authentication) throws AuthenticationException {
        String username = authentication.getName();
        LoginCredential loginCredential = (LoginCredential) authentication.getCredentials();
        String password = loginCredential.getPassword();
        String captcha = loginCredential.getCaptcha();

        UserDetails userDetails = userDetailsService.loadUserByUsername(username);
        if (userDetails == null) {
            throw new UsernameNotFoundException("Invalid username");
        }
        if (!passwordEncoder.matches(password, userDetails.getPassword())) {
            throw new BadCredentialsException("Invalid password");
        }
        HttpServletRequest request = ((ServletRequestAttributes) RequestContextHolder.getRequestAttributes()).getRequest();
        String realCaptchaCode = (String) request.getSession().getAttribute("captchaCode");
        if (!captcha.equals(realCaptchaCode)) {
            throw new BadCredentialsException("Invalid captcha");
        }

        return new UsernamePasswordAuthenticationToken(username, loginCredential, userDetails.getAuthorities());
    }

    @Override
    public boolean supports(Class<?> authentication) {
        return authentication.equals(UsernamePasswordAuthenticationToken.class);
    }
}

代码中,我们自定义的LoginAuthenticationProvider实现了AuthenticationProvider接口。authenticate()方法中我们校验了用户名、密码和验证码3个参数,其中我们将验证码以captchaCode字段存储在Session中,该字段在访问验证码接口时写入。supports()方法返回该Provider是否支持指定的AuthenticationToken类型,我们这里还是支持使用UsernamePasswordAuthenticationToken,这里我们比较取巧的用LoginCredential作为UsernamePasswordAuthenticationToken的Credential字段,其中LoginCredential封装了密码和验证码两个字段(之前例子中只是密码字符串)。

注意:即使没有权限信息,返回UsernamePasswordAuthenticationToken时也务必调用3个参数的构造函数,否则该Authentication的登录状态会被设置为false,这是SpringSecurity的一个坑。

@Bean
public AuthenticationManager authenticationManager(HttpSecurity httpSecurity, LoginAuthenticationProvider loginAuthenticationProvider) throws Exception {
    return httpSecurity
            .getSharedObject(AuthenticationManagerBuilder.class)
            .authenticationProvider(loginAuthenticationProvider)
            .build();
}

SecurityConfiguration配置中,我们注册AuthenticationManager时,传入自定义的LoginAuthenticationProvider。自定义的Provider比内置Provider具有更高的优先级,这样配置后原来的DaoAuthenticationProvider就不会再生效了。

@PostMapping("/login")
public ApiResult<?> login(@RequestBody LoginFormBean loginFormBean) {
    LoginCredential loginCredential = new LoginCredential(loginFormBean.getPassword(), loginFormBean.getCaptcha());
    UsernamePasswordAuthenticationToken token = new UsernamePasswordAuthenticationToken(loginFormBean.getUsername(), loginCredential);
    try {
        Authentication authentication = authenticationManager.authenticate(token);
        SecurityContextHolder.getContext().setAuthentication(authentication);
        return ApiResult.success("登录成功");
    } catch (AuthenticationException e) {
        return ApiResult.failure("用户名或密码错误");
    }
}

在登录接口中,我们组装UsernamePasswordAuthenticationToken并将其传递给authenticationManager,不过此时生效的已经是我们自定义的LoginAuthenticationProvider了。经过我们自定义的Provider处理后,返回了认证结果。

使用Redis存储Session

实际开发中的一个普遍需求是使用Redis存储Session。SpringSecurity从Session存取SecurityContext的操作在SecurityContextPersistenceFilter中实现,但如果只是实现使用Redis存储Session我们并不需要扩展这个过滤器,而是直接使用spring-session-data-redis就行了。

<dependency>
    <groupId>org.springframework.session</groupId>
    <artifactId>spring-session-data-redis</artifactId>
</dependency>
spring.session.store-type=redis
spring.session.timeout=3600

引入SpringSession的依赖并配置后,我们的Session信息就可以存储在Redis中。有关SpringSession的具体使用可以参考相关章节,这里就不多介绍了。

更多认证方式

实际上如果能够捋顺上面最基础的认证流程并掌握相关扩展点,实现更多的认证方式也就不在话下了。不过这里要提一下比较特殊的JWT认证,JWT要求服务端是完全无状态的,我们可以将SecurityContext在Session中的持久化禁用:

httpSecurity.sessionManagement().sessionCreationPolicy(SessionCreationPolicy.STATELESS)

至于如何校验JWT,可以自己添加一个过滤器,通过解析请求头中的JWT向SecurityContextHolder中设置SecurityContext,供SpringSecurity框架后续过滤器链使用。

作者:Gacfox
版权声明:本网站为非盈利性质,文章如非特殊说明均为原创,版权遵循知识共享协议CC BY-NC-ND 4.0进行授权,转载必须署名,禁止用于商业目的或演绎修改后转载。