Microservice微服务Spring.Net

spring security系列教程:基于session:用户

2019-05-26  本文已影响39人  蔺荆门

看完这个教程可以搭建一个基于session的用户名+密码验证的权限框架,适用前后端不分离的项目架构。前后端分离的架构基于token的认证方式后面会详细介绍,现在讲的是基础,切莫好高骛远。

原理

我们之前讲过,spring security基于Filter和Interceptor实现。基本上每一种认证方式都对应着一个或者多个Filter,与这篇教程讲述的用户名+密码认证方式相关的Filter就是UsernamePasswordAuthenticationFilter,大家可在idea里面搜索一下这个类。

结构

UsernamePasswordAuthenticationFilter类结构

主要方法

    public UsernamePasswordAuthenticationFilter() {
        super(new AntPathRequestMatcher("/login", "POST"));
    }
    public Authentication attemptAuthentication(HttpServletRequest request,
            HttpServletResponse response) throws AuthenticationException {
        if (postOnly && !request.getMethod().equals("POST")) {
            throw new AuthenticationServiceException(
                    "Authentication method not supported: " + request.getMethod());
        }

        String username = obtainUsername(request);
        String password = obtainPassword(request);

        if (username == null) {
            username = "";
        }

        if (password == null) {
            password = "";
        }

        username = username.trim();

        UsernamePasswordAuthenticationToken authRequest = new UsernamePasswordAuthenticationToken(
                username, password);//1

        setDetails(request, authRequest);//2

        return this.getAuthenticationManager().authenticate(authRequest);//3
    }

解析

第一个方法配置类该Filter处理的路径和http方法。

第二个方法实现了父类AbstractAuthenticationProcessingFilter的相应方法,作用就是认证请求。在上面的代码中,我用行尾注释(虽然不大提倡,但是为了不影响代码结构只好如此)标注了三个地方,我们来分别解释一下他们都干了什么事情。

  1. 我注释标注1的地方是通过用户名和密码实例化一个未认证Authentication
  2. 我注释标注2的地方是向上一步生成的未认证Authentication里填充一些附加信息,例如ip、sessionId等等。
  3. 我注释标注3的地方是把前两步生成的未认证Authentication交给一个AuthenticationManager类来认证,并返回认证结果。

术语解释
Authentication:这个是spring security封装认证信息的一个接口,用户名密码模式对应的Authentication
UsernamePasswordAuthenticationToken
AuthenticationManager:官方说明是用于处理Authentication的接口

其实整个过程就是,从请求中获取用户名和密码=>用获得的用户名和密码实例化一个未认证的Authentication=>向生成的Authentication中添加一些附加信息=>认证Authentication返回

最后一步,认证Authentication并返回,才是重点,也是难点。

认证Authentication是通过AuthenticationManager这个接口实现的。在代码中通过this.getAuthenticationManager()得到相应的实现类,利用打断点跟踪或者Windows下利用Ctrl+Alt+B快捷键很容易找到认证这个AuthenticationAuthenticationManagerProviderManager这个实现类。类中认证请求的核心代码如下:

        for (AuthenticationProvider provider : getProviders()) {
            if (!provider.supports(toTest)) {
                continue;
            }

            if (debug) {
                logger.debug("Authentication attempt using "
                        + provider.getClass().getName());
            }

            try {
                result = provider.authenticate(authentication);

                if (result != null) {
                    copyDetails(authentication, result);
                    break;
                }
            }
            
            //....
        }

大家自己用idea打开源码的时候就会发现了,我注释打省略号之后的代码是一些异常处理的逻辑,在这里我们就只讲认证的核心代码。其他的大家下来自己去研究一下。

首先,getProviders()得到框架中所有已经配置的ProvidertoTestAuthentication的Class对象,遍历所有的Provider,如果不支持该Authentication,就跳过。支持的话就开始认证了。

术语解释
Provider:指一个处理特定Authentication实现的类

打点跟踪之后发现,认证UsernamePasswordAuthenticationToken的是AbstractUserDetailsAuthenticationProvider

    public Authentication authenticate(Authentication authentication)
        throws AuthenticationException {
        //...

        if (user == null) {
            cacheWasUsed = false;

            try {
                user = retrieveUser(username,
                    (UsernamePasswordAuthenticationToken) authentication);//1
            }

            //...
        }

        //...

        return createSuccessAuthentication(principalToReturn, authentication, user);//2
    }

限于篇幅,这个类我也只贴了核心的代码,省略的那些check用户的过程读者可以自己去研究一下。

我标注1的地方做的是用前面获取到的用户名来加载系统用户。背后的实现类是DaoAuthenticationProvider,代码我就不整个贴了,我给大家贴最关键的一句

UserDetails loadedUser = this.getUserDetailsService().loadUserByUsername(username);

然后从我标注2的这个方法名我们就可以看出,是创建一个成功认证的Authentication返回。也就是上面所有的校验都通过了,创建一个认证成功的Authentication返回。

    protected Authentication createSuccessAuthentication(Object principal,
            Authentication authentication, UserDetails user) {
        
        UsernamePasswordAuthenticationToken result = new UsernamePasswordAuthenticationToken(
                principal, authentication.getCredentials(),
                authoritiesMapper.mapAuthorities(user.getAuthorities()));
        result.setDetails(authentication.getDetails());

        return result;
    }

调用了UsernamePasswordAuthenticationToken的另一个构造方法。

总结

总结下来,整体的流程就是刚才提到的那个从请求中获取用户名和密码=>用获得的用户名和密码实例化一个未认证的Authentication=>向生成的Authentication中添加一些附加信息=>认证Authentication返回,大家把最后一步认证的过程仔细研究一遍也就很直观了。

认证的流程就是从ProviderManager管理的所有Provider中找出一个匹配的,然后调用认证方法完成认证。

到这里我们大概的流程已经跑了一遍了,如果还一头雾水的小伙伴可以反复看几遍。因为覆盖的面比较广,而且源码分析的话本身就会有点复杂,所以笔者可能有些地方写的不是很清楚,大家不明白也可以直接与我交流。

实战

项目搭建起来之后(可以看我上一篇文章,里面讲了如何搭建学习项目),我们的写的rest接口就已经被spring security保护起来了。我们跑起来实验一下。

image.png

访问我们的接口/hello

image.png

会发现它自己重定向到了/login,出现上面这个页面。现在进到了spring security默认的流程了,用户名+密码。(有些小伙伴出现的是httpbasic认证弹窗,那是因为版本的问题,spring security 5.x之后就默认表单登录了)

那有些朋友就会产生疑问了,我们还没有user表呢,哪来的用户名和密码啊?其实这个问题是spring security自动配置好的,用户名是user,密码在启动的时候console里面就打印出来了Using generated security password: 6db606f3-741e-4541-a856-d30e170cb314,在上面的启动图片中也可以看到。我们输入这个用户名和密码之后,点击登录,就会发现浏览器又自动跳转到了/hello接口(前提是没有关闭过浏览器或者复制/login地址到其他浏览器打开)。

image.png

问题

  1. 上面使用的是spring security的默认配置,用户名user+随机密码登录,这肯定不行,那我们如何使用自己的用户数据呢?
  2. 上面当我们登录成功之后,浏览器默认给我们跳转到了之前访问的接口上,这可能也不符合我们的业务要求,我们可能要在用户登录成功之后,给返回一个json,或者给用户加一个积分什么的,那这如何实现呢?
  3. 上面的登录页面是spring security默认提供的,我们需要用自己的登录页面替换怎么操作呢?

解决方案

  1. 扩展spring security,验证自己的用户数据

在上面的原理部分讲到spring security默认的用户名+密码方式获取用户信息是通过

UserDetails loadedUser = this.getUserDetailsService().loadUserByUsername(username);

这样的方式。其中UserDetailsService是一个接口,长这样

public interface UserDetailsService {
    
    UserDetails loadUserByUsername(String username) throws UsernameNotFoundException;
}

很明显,封装了一个获取用户信息的逻辑。输入是用户名,返回一个UserDetais对象,再来看看这个UserDetails对象长啥样:

public interface UserDetails extends Serializable {

    Collection<? extends GrantedAuthority> getAuthorities();//获取权限

    String getPassword();//获取密码

    String getUsername();//获取用户名

    boolean isAccountNonExpired();//判断账户是否过期  true代表没有过期 false代表过期

    boolean isAccountNonLocked();//判断账户是否锁定 true代表未锁定 false代表锁定

    boolean isCredentialsNonExpired();//判断密码是否过期 true代表没过期 false代表过期

    boolean isEnabled();//判断账户是否可用 true代表可用 false代表不可用
}

上面的代码我写了注释,很明显,这个接口封装了获取用户信息的能力。那么我们自己系统中的用户表实体类就需要实现这个接口,进而实现这些方法。

后面这四个返回布尔的方法可根据自己系统的规则计算结果返回,或者如果没有这些判断就直接返回true。

第一个返回权限集合的方法,读者可以自己去研究一下GrantedAuthority接口。spring security默认使用的是SimpleGrantedAuthority实现,读者也可以直接构造一个SimpleGrantedAuthority的集合返回即可。这里返回的权限可能影响到之后的教程中介绍的权限表达式的执行结果,例如这里返回一个“admin”权限,后面可以配置一个接口需要“admin”权限才可以访问。

介绍到这,解决方案就很明显了,就是用户表实体类实现UserDetails接口,自己再实现一个UserDetailsService,并且让spring security使用它。这样就能让spring security使用我们自己的用户数据来登录。

直接来代码:

import lombok.extern.slf4j.Slf4j;
import org.springframework.security.core.authority.AuthorityUtils;
import org.springframework.security.core.userdetails.User;
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.Component;

@Slf4j
@Component
public class MyUserDetailsService implements UserDetailsService {


    @Override
    public UserDetails loadUserByUsername(String username) throws UsernameNotFoundException {

        log.info(String.format("用户%s登录", username));
        return new User(username, "123456", AuthorityUtils.commaSeparatedStringToAuthorityList("admin"));
    }
}

在这里我没有真正去通过jdbc查询用户信息。我这里就直接打印一下日志,然后构造一个UserDetails返回。特别注意:这个类里面只是获取用户信息,而不是做校验。校验是在上面说的AbstractUserDetailsAuthenticationProvider中实现的。上面这个UserDetailsService里的逻辑是用户名不限,然后密码固定为123456。需要特别注意的是要把上面这个类通过@component注入到spring容器里面,这样才能替代spring security默认的UserDetailsService

上面使用的User对象是spring security提供的一个User对象,实现了UserDetails接口,上面代码中使用的构造方法参数依次为用户名密码权限集合。生成权限集合我用的是一个spring security工具类AuthorityUtilscommaSeparatedStringToAuthorityList方法,这个方法的作用就是将一个用逗号隔开的权限字符串转换为权限集合。读者自己实现的时候可以看看这个方法的实现,或者一步到位直接上数据库,实现用户表,然后在上面这个类里面注入数据库连接的工具查询用户,如果查不到用户,可直接抛UsernameNotFoundException异常。

好了,我们现在启动项目,并且访问/hello接口,像之前一样,会跳转到'/login'接口。然后我输入用户名syhc,密码123456,点击登录。然后发现一直没反应,这是为何?会到后端项目中查看console,会发现打印了这些东西:

image.png

报错了。意思是没有PasswordEncoder,也就是找不到相应的PasswordEncoder。这个东西到底是干啥的呢?从名字就可以看出来,这个是一个密码编码器。我们知道在业务系统中是不能明文存储密码的,因为如果明文存储密码,那么数据库被拖库之后用户信息就直接暴露了(而且还可能会被嘲笑!!)。正确的方法应该是在用户注册的时候,将用户设置的密码,加密编码之后再存入数据库,然后在校验的时候解密校验。

没错,spring security为我们提供了一个很好的密码编码器BCryptPasswordEncoder。我们只需要将其注入进来即可。修改上面的MyUserDetailsService

import lombok.extern.slf4j.Slf4j;
import org.springframework.context.annotation.Bean;
import org.springframework.security.core.authority.AuthorityUtils;
import org.springframework.security.core.userdetails.User;
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.bcrypt.BCryptPasswordEncoder;
import org.springframework.security.crypto.password.PasswordEncoder;
import org.springframework.stereotype.Component;

@Slf4j
@Component
public class MyUserDetailsService implements UserDetailsService {


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


    @Override
    public UserDetails loadUserByUsername(String username) throws UsernameNotFoundException {

        log.info("用户{}登录", username);
        return new User(username, passwordEncoder().encode("123456"), AuthorityUtils.commaSeparatedStringToAuthorityList("admin"));
    }
}

详细对比上面的实现和刚才修改过后的实现可以发现后面修改的内容不仅是将密码编码器注入spring,而且还将构造用户对象的密码加密了,spring security在后面验证的时候会使用你注入的密码编码器验证密码的。正常的流程应该是在用户注册的时候使用这个encode方法加密密码之后再存入数据库。

到这我们就搞定了,我们可以试一下,用户名syhc,密码123456。结果

image.png

bingo!

  1. 更改登录成功之后的默认实现

默认的登录成功处理逻辑是跳转到登录之前访问的接口上,现在我们要返回json适应我们的系统(也可能是其他操作,都是一样的)。怎么做呢?这里其实很简单,就是实现一个接口,这个接口是AuthenticationSuccessHandler。我们先来看看我们原理里边讲的UsernamePasswordAuthenticationFilter里面的attemptAuthentication这个方法返回认证成功的Authentication之后,spring security做了什么。

首先返回到了AbstractAuthenticationProcessingFilter里边

    public void doFilter(ServletRequest req, ServletResponse res, FilterChain chain)
            throws IOException, ServletException {
        //...
        try {
            authResult = attemptAuthentication(request, response);
            if (authResult == null) {
                // return immediately as subclass has indicated that it hasn't completed
                // authentication
                return;
            }
            sessionStrategy.onAuthentication(authResult, request, response);
        }

        //...
        successfulAuthentication(request, response, chain, authResult);
    }

同样我也只贴了最核心的代码,可以看到,认证完了之后,如果认证成功了,会进入最后一个方法里

    protected void successfulAuthentication(HttpServletRequest request,
            HttpServletResponse response, FilterChain chain, Authentication authResult)
            throws IOException, ServletException {

        if (logger.isDebugEnabled()) {
            logger.debug("Authentication success. Updating SecurityContextHolder to contain: "
                    + authResult);
        }

        SecurityContextHolder.getContext().setAuthentication(authResult);

        rememberMeServices.loginSuccess(request, response, authResult);

        // Fire event
        if (this.eventPublisher != null) {
            eventPublisher.publishEvent(new InteractiveAuthenticationSuccessEvent(
                    authResult, this.getClass()));
        }

        successHandler.onAuthenticationSuccess(request, response, authResult);
    }

在这个方法里spring security将认证成功的Authentication放到了SecurityContextHolder里。最后调用了successHandler处理成功请求。这个successHandler我们往上倒一下就可以看到定义

private AuthenticationSuccessHandler successHandler = new SavedRequestAwareAuthenticationSuccessHandler();

这个SavedRequestAwareAuthenticationSuccessHandler我就不给大家贴代码了,它的作用就是我们之前看到的那样,认证成功之后跳转到之前访问的接口。

到这里我们就把spring security的成功处理逻辑理清楚了,我们需要改变这一块的逻辑那就需要自己去实现SuccessHandler,然后配置spring security使用。

import com.alibaba.fastjson.JSONObject;
import lombok.extern.slf4j.Slf4j;
import org.springframework.security.core.Authentication;
import org.springframework.security.web.authentication.AuthenticationSuccessHandler;

import javax.servlet.http.HttpServletRequest;
import javax.servlet.http.HttpServletResponse;
import java.io.IOException;

@Slf4j
public class JSONAuthenticationSuccessHandler implements AuthenticationSuccessHandler {

    @Override
    public void onAuthenticationSuccess(HttpServletRequest request, HttpServletResponse response, Authentication authentication) throws IOException {

        log.info("登录成功,用户信息为:{}", authentication.toString());
        JSONObject returnJSON = new JSONObject();
        returnJSON.put("status", "success");
        printJSON(response, returnJSON);
    }

    private void printJSON(HttpServletResponse response, JSONObject returnData) throws IOException {
        response.setCharacterEncoding("utf-8");
        response.setContentType("application/json;charset=utf-8");
        response.getWriter().print(returnData.toString());
    }
}

这里的json序列化工具笔者用的是阿里的fastjson,maven依赖如下

<dependency>
    <groupId>com.alibaba</groupId>
    <artifactId>fastjson</artifactId>
    <version>1.2.58</version>
</dependency>

然后配置spring security,让我们写的successHandler生效。

新建一个配置类

import com.syhc.security.handler.JSONAuthenticationSuccessHandler;
import org.springframework.context.annotation.Configuration;
import org.springframework.security.config.annotation.web.builders.HttpSecurity;
import org.springframework.security.config.annotation.web.configuration.WebSecurityConfigurerAdapter;

@Configuration
public class WebSecurityConfig extends WebSecurityConfigurerAdapter {

    private JSONAuthenticationSuccessHandler successHandler = new JSONAuthenticationSuccessHandler();

    @Override
    protected void configure(HttpSecurity http) throws Exception {
        http.formLogin()
            .successHandler(successHandler)
            .and()
            .authorizeRequests()
            .anyRequest()
            .authenticated();
    }
}

继承WebSecurityConfigurerAdapter类来配置spring security。然后重写configure方法,注意configure方法有多个,但是参数不一样,需要重写参数为HttpSecurity的方法。其他的几个方法会在后面讲到。

大家看到这里可能会觉得有点懵,为啥要这么配置啊?其实大家只需要记住配置SuccessHandler只需要前两行配置就行了,后面的四行配置是权限表达式的内容,后面会介绍。但是这里必须要像这样配置,因为不配置的话spring security就会认为你的接口都不需要验证,访问接口的话是没有任何拦截直接可以访问的。为啥前面都不需要配置,这里突然需要配置这个东西呢?是因为spring security默认的配置就是全部请求都需要配置,然后现在我们重写了它的配置,原来的配置不生效了,所以我们就需要自己把它加上。

现在我们完成登录请求之后,返回是这样


image.png

补充:扩展登录成功的处理行为我们演示了,那扩展登录失败的处理行为怎么办呢?没错,和扩展登录成功的处理行为基本上一模一样,实现一个AuthenticationFailureHandler接口就完事了。

  1. 替换登录页

spring security现在默认的登录页是这个样子


image.png

虽然也很简洁,很美观,但是可能满足不了大家的需求。如何让spring security使用我们自己的登录页呢?

首先新建一个自己的登录页


image.png

在resources目录下再建一个resources目录,然后新建一个html文件

<!DOCTYPE html>
<html lang="en">
<head>
    <meta charset="UTF-8">
    <title>login</title>
</head>
<body>
<form action="/login" method="post">
    <input type="text" name="username"/>
    <br>
    <input type="password" name="password">
    <br>
    <button type="submit">提交</button>
</form>
</body>
</html>

这个登录页里边有一些东西是必须要和我的一样的,其他的各位自行发挥。

好了,现在我们的登录页有了,我们需要去配置spring security了。

    protected void configure(HttpSecurity http) throws Exception {
        http.formLogin()
            .successHandler(successHandler)
            .loginPage("/login.html")
            .loginProcessingUrl("/login")
            .and()
            .authorizeRequests()
            .anyRequest()
            .authenticated();
    }

在刚才的基础上加了两条配置。配置了自定义登录页的路径,还配置了处理自定义登录页登录请求的接口地址,这个接口地址就是form表单action属性里填写的地址。好了现在我们访问接口,看看效果

image.png
无限循环重定向,这是咋肥四呢?其实,这是因为我们配置了登录页为/login.html,当我们访问接口/hello的时候,系统检测到我们未登录,就引导我们到登录页面/login.html。然后访问登录页面的时候由于我们配置了所有的接口都需要权限验证,那么系统就会检测我们是否登录,发现没有登录,就会再次重定向到/login.html,就这样循环往复。

找到问题的原因了,怎么解决呢?对,就是将/login.html放行,不验证权限。

    protected void configure(HttpSecurity http) throws Exception {
        http.formLogin()
            .successHandler(successHandler)
            .loginPage("/login.html")
            .loginProcessingUrl("/login")
            .and()
            .authorizeRequests()
            .antMatchers("/login.html").permitAll()
            .anyRequest()
            .authenticated();
    }

大家如果看不太明白,就照着我的写,后面我们讲权限表达式的时候就会讲这个了。

再次访问接口

image.png

可以了,就是有点太丑了。但是这没关系,我写的丑我相信读者你写的肯定很漂亮。

我们输入用户名密码登录,会发现点击登录之后会一直跳转回登录页,这是怎么回事呢?我们先去看看spring security默认的登录页是怎么写的。


image.png

我们发现,spring security默认的登录页上面有一个隐藏的input,里面的值是一个UUID,name是_csrf。明白了,spring security默认开启了csrf攻击的防护。查阅源码之后就会明白需要在请求中添加一个csrf token的参数,在这里因为篇幅限制我就不展开说spring security的csrf防护了,后面我会写一篇文章讲这个。

现在怎么办呢?我们可以在我们的登录页添加csrf的token参数,但是我们的项目没有引入模板引擎,所以取到csrf的token比较折腾,token存储在request对象里。所以我们选择把spring security默认的csrf防护关闭(可以配置csrf不保护的url列表,但是为了后面的教程比较顺利,就先暂时关闭吧)。

看配置

    protected void configure(HttpSecurity http) throws Exception {
        http.formLogin()
            .successHandler(successHandler)
            .loginPage("/login.html")
            .loginProcessingUrl("/login")
            .and()
            .authorizeRequests()
            .antMatchers("/login.html").permitAll()
            .anyRequest()
            .authenticated()
            .and()
            .csrf().disable();
    }

关闭了csrf之后我们再次登录


image.png

bingo!

总结

实战到这里我们就结束了,如果读者的项目是前后端未分离架构的话到现在其实一个简单的用户名+密码的权限认证框架就搭建起来了,再加上后面会讲的一些权限表达式就完整了。
总结一下实战的内容

  1. 需要让spring security使用我们自己的user数据的话,需要user表实体类实现UserDetails接口,并且自己写一个UserDetailsService接口的实现,注入spring即可。
  2. 扩展spring security的登录成功处理需要实现AuthenticationSuccessHandler接口,并且配置spring security让其使用我们自己写的SuccessHandler
  3. 自定义登录页面需要配置spring security,需要注意的几点就是:

好了,就到这。

更多系列教程请看 spring security系列教程

上一篇 下一篇

猜你喜欢

热点阅读