安全登录流程实战及流程分析

用户打开登录界面,输入用户名及密码后,点击登录

下面直接贴出登录校验代码,然后再进行分析

@Log("用户登录")
    @ApiOperation("登录授权")
    @AnonymousAccess
    @PostMapping(value = "/login")
    public ResponseEntity<Object> login(@Validated @RequestBody AuthUser authUser, HttpServletRequest request){
        // 密码解密
        RSA rsa = new RSA(privateKey, null);
        String password = new String(rsa.decrypt(authUser.getPassword(), KeyType.PrivateKey));
        // 查询验证码
        String code = (String) redisUtils.get(authUser.getUuid());
        // 清除验证码
        redisUtils.del(authUser.getUuid());
        if (StringUtils.isBlank(code)) {
            throw new BadRequestException("验证码不存在或已过期");
        }
        if (StringUtils.isBlank(authUser.getCode()) || !authUser.getCode().equalsIgnoreCase(code)) {
            throw new BadRequestException("验证码错误");
        }
        UsernamePasswordAuthenticationToken authenticationToken =
                new UsernamePasswordAuthenticationToken(authUser.getUsername(), password);

        Authentication authentication = authenticationManagerBuilder.getObject().authenticate(authenticationToken);
        SecurityContextHolder.getContext().setAuthentication(authentication);
        // 生成令牌
        String token = tokenProvider.createToken(authentication);
        final JwtUser jwtUser = (JwtUser) authentication.getPrincipal();
        // 保存在线信息
        onlineUserService.save(jwtUser, token, request);
        // 返回 token 与 用户信息
        Map<String,Object> authInfo = new HashMap<String,Object>(2){{
            put("token", properties.getTokenStartWith() + token);
            put("user", jwtUser);
        }};
        if(singleLogin){
            //踢掉之前已经登录的token
            onlineUserService.checkLoginOnUser(authUser.getUsername(),token);
        }
        return ResponseEntity.ok(authInfo);
    }

 

密码加解密及验证码实现前面已经介绍过,此处不再分析

UsernamePasswordAuthenticationToken authenticationToken =
                new UsernamePasswordAuthenticationToken(authUser.getUsername(), password);

        Authentication authentication = authenticationManagerBuilder.getObject().authenticate(authenticationToken);
        SecurityContextHolder.getContext().setAuthentication(authentication);

此处三行代码讲的是

Spring Security中将使用username和password封装成Authentication的实现声明为了authenticationToken

在讲解authenticationToken之前先从开始分析一下登录流程

Spring Security是通过AbstractAuthenticationProcessingFilter向Web应用向基于HTTP、浏览器的请求提供身份验证服务

UsernamePasswordAuthenticationFilter类的说明

UsernamePasswordAuthenticationFilter是AbstractAuthenticationProcessingFilter针对使用用户名和密码进行身份验证而定制化的一个过滤器。
在一开始我们先通过下面的配图来回忆一下AbstractAuthenticationProcessingFilter的在框架中的角色与职责。

 

AbstractAuthenticationProcessingFilter

AbstractAuthenticationProcessingFilter在整个身份验证的流程中主要处理的工作就是所有与Web资源相关的事情,并且将其封装成Authentication对象,最后调用AuthenticationManager的验证方法。所以UsernamePasswordAuthenticationFilter的工作大致也是如此,只不过在这个场景下更加明确了Authentication对象的封装数据的来源和形式——使用用户名和密码。

接着我们再对AbstractAuthenticationProcessingFilter的属性和方法做一个快速的了解。UsernamePasswordAuthenticationFilter继承扩展了AbstractAuthenticationProcessingFilter,相对与AbstractAuthenticationProcessingFilter而言主要有以下几个改动:

  1. 属性中增加了username和password字段;
  2. 强制的只对POST请求应用;
  3. 重写了attemptAuthentication身份验证入口方法。

UsernamePasswordAuthenticationFilter的属性中额外增加了username和password字段

封装用户名密码的基石:UsernamePasswordAuthenticationToken

在UsernamePasswordAuthenticationFilter的属性声明中额外增加了username和password的动机很容易明白,即需要从HttpRequest中获取对应的参数字段,并将其封装进Authentication中传递给AuthenticationManager进行身份验证。这里让我们回顾下Authentication到底是什么?Authentication是一个接口声明,一个特定行为的声明,它并不是一个类,没有办法实例化为对象进行传递。所以我们首先需要对Authentication进行实现,使其可以被实例化。

Authentication接口声明


在UsernamePasswordAuthenticationFilter的身份验证设计里,我们需要验证协议用简单的语言可以描述为:给我一组用户名和密码,如果匹配,那么就算验证成功。用户名即是一个唯一可以标识不同用户的字段,而密码则是检验当前的身份验证是否正确的凭证信息。在Spring Security中便将使用username和password封装成Authentication的实现声明为了UsernamePasswordAuthenticationToken

 

 

UsernamePasswordAuthenticationToken继承了AbstractAuthenticationToken抽象类,其主要与AbstractAuthenticationToken的区分就是针对使用用户名和密码验证的请求按照约定进行了一定的封装:将username赋值到了principal ,而将password赋值到了credentials。

    public UsernamePasswordAuthenticationToken(Object principal, Object credentials,
            Collection<? extends GrantedAuthority> authorities) {
        super(authorities);
        this.principal = principal;
        this.credentials = credentials;
        super.setAuthenticated(true); // must use super, as we override
    }

通过UsernamePasswordAuthenticationToken实例化了Authentication接口,继而按照流程,将其传递给AuthenticationMananger调用身份验证核心完成相关工作。

        UsernamePasswordAuthenticationToken authRequest = new UsernamePasswordAuthenticationToken(
                username, password);

        // Allow subclasses to set the "details" property
        setDetails(request, authRequest);

        return this.getAuthenticationManager().authenticate(authRequest);

以上将来自HTTP请求中的参数按照预先约定放入赋值给Authentication指定属性,便是UsernamePasswordAuthenticationFilter部分最主要的改动。

验证核心的工作者:AuthenticationProvider

Web层的工作已经完成了,Authentication接口的实现类UsernamePasswordAuthenticationToken通过AuthenticationMananger提供的验证方法作为参数被传递到了身份验证的核心组件中。
我们曾多次强调过一个设计概念:AuthenticationManager接口设计上并不是用于完成特定的身份验证工作的,而是调用其所配发的AuthenticationProvider接口去实现的。
那么这里就有一个疑问,针对接口声明参数声明的Authentication,针对不同验证协议的AuthenticationProvider的实现类们是完成对应的工作的,并且AuthenticationManager是如何知道应该使用哪一个AuthenticationProvider才能完成对应协议的验证工作?
那么我们首先先复习下验证核心的大明星AuthenticationProvider接口的声明:

 

AuthenticationProvider接口声明

 

AuthenticationProvider只包含两个方法声明,核心验证方法入口:

    Authentication authenticate(Authentication authentication)
            throws AuthenticationException;

另外一个便是让AuthenticationManager可以通过调用该方法辨别当前AuthenticationProvider是否是完成相应验证工作的supports方法:

     boolean supports(Class<?> authentication);

简单的描述便是AuthenticationProvider只有两个方法,一个是它不能验证当前的Authentication,还有便是让他去验证当前的Authentication。
对于AuthenticationProvider整个体系能说的非常多,本期只对我们“需要了解”的AuthenticationProvider中两个接口声明的方法做个最简单的说明。其他部分在以后单独对AuthenticationProvider体系介绍的时候再进一步展开。

是否支持当前验证协议:boolean supports(Class<?> authentication

在Spring Security中唯一AuthenticationManager的实现类ProviderManager,在处理authenticate身份验证入口方法的时,首先第一解决的问题便是:我手下哪个AuthenticationProvider能验证当前传入的Authentication?为此ProviderManager便会对其所有的AuthenticationProvider做supports方法检测,直到有AuthenticationProvider能在supports方法被调用后返回true。

我们了解了框架上的设计逻辑:先要知道知道谁能处理当前的身份验证信息请求再要求它进行验证工作。
回到我们的场景上来:UsernamePasswordAuthenticationFilter已经封装好了一个UsernamePasswordAuthenticationToken传递给了ProviderMananger。紧接着当前ProviderMananger询问哪个AuthenticationProvider能支持这个Authentication的实现类。此时ProviderMananger所处的情况大概就跟下图一般困惑:

 

ProviderMananger

在ProviderMananger的视角里,所有的Authentication实现类都不具名,它不仅不能通过自身完成验证工作也不能独立完成判断是否支持的工作,而是统统交给AuthenticationProvider去完成。而不同的AuthenticationProvider开发初衷本就是为了支持指定的某种验证协议,所以在特定的AuthenticationProvider的视角中,他只关心当前Authentication是不是他预先设计处理的类型即可。
在使用用户名和密码的验证场景中,验证使用的用户名和密码被封装成了UsernamePasswordAuthenticationToken对象。Spring Security便为了向UsernamePasswordAuthenticationToken对象在核心层提供相关的验证服务便继承AuthenticationProvider开发了使用用户名和密码与UserDetailsService交互并且验证密码的。
DaoAuthenticationProvider是DaoAuthenticationProvider的实现类,DaoAuthenticationProvider针对UsernamePasswordAuthenticationToken的大部分逻辑都是通过AbstractUserDetailsAuthenticationProvider完成的。比如针对ProviderManager询问是否支持当前Authentication的supports方法:

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

可能有些同人对isAssignableFrom方法比较陌生,这是一个判断两个类之间是否存在继承关系使用的判断方法,DaoAuthenticationProvider会判断当前的Authentication的实现类是否是UsernamePasswordAuthenticationToken它本身,或者是扩展了UsernamePasswordAuthenticationToken的子孙类。返回true的场景只有一种,便是当前的Authentication是UsernamePasswordAuthenticationToken实现,换言之便是DaoAuthenticationProvider设计上需要进行处理的某种特定的验证协议的信息载体的实现。

核心验证逻辑:Authentication authenticate(Authentication authentication)

完成了是否支持的supports验证后,ProviderMananger便会全权将验证工作交由DaoAuthenticationProvider进行处理了。与ProviderMananger最不同一点是,在DaoAuthenticationProvider的视角里,当前的Authentication最起码一定是UsernamePasswordAuthenticationToken的形式了,不用和ProviderMananger一样因为匮乏信息而不知道干什么。
在DaoAuthenticationProvider分别会按照预先设计一样分别从principal和credentials获取用户名和密码进行验证。

String username = (authentication.getPrincipal() == null) ? "NONE_PROVIDED"
                : authentication.getName();

String presentedPassword = authentication.getCredentials().toString();

接着便是按照我们熟悉的预先设计流程,通过UserDetailsService使用username获取对应的UserDetails,最后通过对比密码是否一致,向PrivoderManager返回最终的身份验证结果与身份信息。这样一个特定场景使用用户名和密码的验证流程就完成了。

小结

我们先来总结下,当前出现过的针对用户名和密码扩展过的类与其为何被扩展的原因。

  1. UsernamePasswordAuthenticationFilter扩展AbstractAuthenticationProcessingFilter,因为需要从HTTP请求中从指定名称的参数获取用户名和密码,并且传递给验证核心;
  2. UsernamePasswordAuthenticationToken扩展Authentication,因为我们设计了一套约定将用户名和密码放入了指定的属性中以便核心读取使用;
  3. DaoAuthenticationProvider 扩展AuthenticationProvider,因为我们需要在核心中对UsernamePasswordAuthenticationToken进行处理,并按照约定读出用户名和密码使其可以进行身份验证操作。

客制化验证协议过程中涉及扩展的类

下面再看

 public String createToken(Authentication authentication) {
      String authorities = authentication.getAuthorities().stream()
         .map(GrantedAuthority::getAuthority)
         .collect(Collectors.joining(","));

      long now = (new Date()).getTime();
      Date validity = new Date(now + properties.getTokenValidityInSeconds());

      return Jwts.builder()
         .setSubject(authentication.getName())
         .claim(AUTHORITIES_KEY, authorities)
         .signWith(key, SignatureAlgorithm.HS512)
         .setExpiration(validity)
         .compact();
   }

 

final JwtUser jwtUser = (JwtUser) authentication.getPrincipal();
        // 保存在线信息
        onlineUserService.save(jwtUser, token, request);

 /**
     * 保存在线用户信息
     * @param jwtUser /
     * @param token /
     * @param request /
     */
    public void save(JwtUser jwtUser, String token, HttpServletRequest request){
        String job = jwtUser.getDept() + "/" + jwtUser.getJob();

//获取ip
        String ip = StringUtils.getIp(request);
        String browser = StringUtils.getBrowser(request);
        //String address = StringUtils.getCityInfo(ip);
        String address = "北京";
        OnlineUser onlineUser = null;
        try {
            onlineUser = new OnlineUser(jwtUser.getUsername(), jwtUser.getNickName(), job, browser , ip, address, EncryptUtils.desEncrypt(token), new Date());
        } catch (Exception e) {
            e.printStackTrace();
        }
        redisUtils.set(properties.getOnlineKey() + token, onlineUser, properties.getTokenValidityInSeconds()/1000);
    }
 

 

@Getter
@AllArgsConstructor
public class JwtUser implements UserDetails {

    private final Long id;

    private final String username;

    private final String nickName;

    private final String sex;

    @JsonIgnore
    private final String password;

    private final String avatar;

    private final String email;

    private final String phone;

    private final String dept;

    private final String job;

    @JsonIgnore
    private final Collection<GrantedAuthority> authorities;

    private final boolean enabled;

    private Timestamp createTime;

    @JsonIgnore
    private final Date lastPasswordResetDate;

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

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

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

    @JsonIgnore
    @Override
    public String getPassword() {
        return password;
    }

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

    public Collection getRoles() {
        return authorities.stream().map(GrantedAuthority::getAuthority).collect(Collectors.toSet());
    }
}

 

// 返回 token 与 用户信息
        Map<String,Object> authInfo = new HashMap<String,Object>(2){{
            put("token", properties.getTokenStartWith() + token);
            put("user", jwtUser);
        }};

//公共文件配置是否为用户只登录一次
        if(singleLogin){
            //踢掉之前已经登录的token
            onlineUserService.checkLoginOnUser(authUser.getUsername(),token);
        }

 /**
     * 检测用户是否在之前已经登录,已经登录踢下线
     * @param userName 用户名
     */
    public void checkLoginOnUser(String userName, String igoreToken){
        List<OnlineUser> onlineUsers = getAll(userName,0);
        if(onlineUsers ==null || onlineUsers.isEmpty()){
            return;
        }
        for(OnlineUser onlineUser:onlineUsers){
            if(onlineUser.getUserName().equals(userName)){
                try {
                    String token =EncryptUtils.desDecrypt(onlineUser.getKey());
                    if(StringUtils.isNotBlank(igoreToken)&&!igoreToken.equals(token)){
                        this.kickOut(onlineUser.getKey());
                    }else if(StringUtils.isBlank(igoreToken)){
                        this.kickOut(onlineUser.getKey());
                    }
                } catch (Exception e) {
                    log.error("checkUser is error",e);
                }
            }
        }
    }

 

 /**
     * 踢出用户
     * @param key /
     * @throws Exception /
     */
    public void kickOut(String key) throws Exception {
        key = properties.getOnlineKey() + EncryptUtils.desDecrypt(key);
        redisUtils.del(key);

    }
 

 

评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

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

余额充值