常见的登录验证的方式

1、http basic auth

每次请求API时都提供用户的username和password。

容易把账号密码暴露给第三方客户端,在生产环境下被使用的越来越少。

2、Oauth

OAuth(开放授权)是一个开放的授权标准,允许用户让第三方应用访问该用户在某一web服务上存储的私密的资源(如照片,视频,联系人列表),而无需将用户名和密码提供给第三方应用。

OAuth允许用户提供一个令牌,而不是用户名和密码来访问他们存放在特定服务提供者的数据。每一个令牌授权一个特定的第三方系统(例如,视频编辑网站)在特定的时段(例如,接下来的2小时内)内访问特定的资源(例如仅仅是某一相册中的视频)。这样,OAuth让用户可以授权第三方网站访问他们存储在另外服务提供者的某些特定信息,而非所有内容 。

     +--------+                               +---------------+
     |        |--(A)- Authorization Request ->|   Resource    |
     |        |                               |     Owner     |
     |        |<-(B)-- Authorization Grant ---|               |
     |        |                               +---------------+
     |        |
     |        |                               +---------------+
     |        |--(C)-- Authorization Grant -->| Authorization |
     | Client |                               |     Server    |
     |        |<-(D)----- Access Token -------|               |
     |        |                               +---------------+
     |        |
     |        |                               +---------------+
     |        |--(E)----- Access Token ------>|    Resource   |
     |        |                               |     Server    |
     |        |<-(F)--- Protected Resource ---|               |
     +--------+                               +---------------+

3、Cookies-session Auth

Cookie-session认证机制就是为一次请求认证在服务端创建一个Session对象,同时在客户端的浏览器端创建了一个Cookie对象;通过客户端带上来Cookie对象来与服务器端的session对象匹配来实现状态管理的。默认的,当我们关闭浏览器的时候,cookie会被删除。但可以通过修改cookieexpire time使cookie在一定时间内有效。Session 是存储在服务器端的,避免在客户端 Cookie 中存储敏感数据。Session 可以存储在 HTTP 服务器的内存中,也可以存在内存数据库(如redis)中。
但是这种基于cookie-session的认证使应用本身很难得到扩展,随着不同客户端用户的增加,独立的服务器已无法承载更多的用户,而这时候基于session认证应用的问题就会暴露出来。

  • Session :每个用户经过我们的应用认证之后,我们的应用都要在服务端做一次记录,以方便用户下次请求的鉴别,通常而言session都是保存在内存中,而随着认证用户的增多,服务端的开销会明显增大。

  • 扩展性 :用户认证之后,服务端做认证记录,如果认证的记录被保存在内存中的话,这意味着用户下次请求还必须要请求在这台服务器上,这样才能拿到授权的资源,这样在分布式的应用上,相应的限制了负载均衡器的能力。这也意味着限制了应用的扩展能力。

  • CSRF :因为是基于cookie来进行用户识别的, cookie如果被截获,用户就会很容易受到跨站请求伪造的攻击。

4、Token Auth

基于token的鉴权机制类似于http协议也是无状态的,它不需要在服务端去保留用户的认证信息或者会话信息。这就意味着基于token认证机制的应用不需要去考虑用户在哪一台服务器登录了,这就为应用的扩展提供了便利。

流程

  • 用户使用用户名密码来请求服务器
  • 服务器进行验证用户的信息
  • 服务器通过验证发送给用户一个token
  • 客户端存储token,并在每次请求时附送上这个token值
  • 服务端验证token值,并返回数据

这个token必须要在每次请求时传递给服务端,它应该保存在请求头里, 另外,服务端要支持CORS(跨来源资源共享)策略,一般我们在服务端这么做就可以了Access-Control-Allow-Origin。

Token Auth的优点

  • 支持跨域访问
    Cookie是不允许垮域访问的,这一点对Token机制是不存在的,前提是传输的用户认证信息通过HTTP头传输.

  • 无状态(服务端可扩展行)
    Token机制在服务端不需要存储session信息,因为Token 自身包含了所有登录用户的信息,只需要在客户端的cookie或本地介质存储状态信息.

  • 更适用CDN:
    可以通过内容分发网络请求你服务端的所有资料(如:javascript,HTML,图片等),而你的服务端只要提供API即可.

  • 去耦:
    不需要绑定到一个特定的身份验证方案。Token可以在任何地方生成,只要在你的API被调用的时候,你可以进行Token生成调用即可.

  • 更适用于移动应用
    当你的客户端是一个原生平台(iOS, Android,Windows 8等)时,Cookie是不被支持的(你需要通过Cookie容器进行处理),这时采用Token认证机制就会简单得多。

  • CSRF(跨站请求伪造Cross-site request forgery)

    因为不再依赖于Cookie,所以你就不需要考虑对CSRF(跨站请求伪造)的防范。

  • 性能:
    一次网络往返时间(通过数据库查询session信息)总比做一次HMACSHA256计算 的Token验证和解析要费时得多.

  • 不需要为登录页面做特殊处理:
    如果你使用Protractor 做功能测试的时候,不再需要为登录页面做特殊处理.

  • 基于标准化
    你的API可以采用标准化的 JSON Web Token (JWT). 这个标准已经存在多个后端库(.NET, Ruby, Java,Python, PHP)和多家公司的支持(如:Firebase,Google, Microsoft)

缺点

  • 存在泄露的风险。如果别人拿到你的 Token,在 Token过期之前,都可以以你的身份在别的地方登录

  • 如果存在 Web Storage(指sessionStorage和localStorage)。由于Web Storage 可以被同源下的JavaScript直接获取到,这也就意味着网站下所有的JavaScript代码都可以获取到web Storage,这就给了XSS机会

  • 如果存在Cookie中。虽然存在Cookie可以使用HttpOnly来防止XSS,但是使用 Cookie 却又引发了CSRF

对Token认证的五点认识

  • 一个Token就是一些信息的集合;
  • 在Token中包含足够多的信息,以便在后续请求中减少查询数据库的几率;
  • 服务端需要对cookie和HTTP Authrorization Header进行Token信息的检查;
  • 基于上一点,你可以用一套token认证代码来面对浏览器类客户端和非浏览器类客户端;
  • 因为token是被签名的,所以我们可以认为一个可以解码认证通过的token是由我们系统发放的,其中带的信息是合法有效的;

5、JWT

  • 身份认证
    在这种场景下,一旦用户完成了登陆,在接下来的每个请求中包含JWT,可以用来验证用户身份以及对路由,服务和资源的访问权限进行验证。由于它的开销非常小,可以轻松的在不同域名的系统中传递,所有目前在单点登录(SSO)中比较广泛的使用了该技术。

  • 信息交换
    在通信的双方之间使用JWT对数据进行编码是一种非常安全的方式,由于它的信息是经过签名的,可以确保发送者发送的信息是没有经过伪造的。

6、SSO

SSO(Single Sign On)单点登录。

指在多系统应用群中登录一个系统,便可在其他所有系统中得到授权而无需再次登录,包括单点登录与单点注销两部分。

实现方式:

  • 有一个公共缓存cache用来验证登录
  • cookie保存登录信息

Http Session

代码🌰:

LoginController

@Controller
public class LoginController {
    @RequestMapping("/user/login")
    public String login(@RequestParam("username") String username,
                        @RequestParam("password") String password,
                        Model model,
                        HttpSession session){
        if (!StringUtils.isEmpty(username) && "123456".equals(password)){
            session.setAttribute("loginUser",username);
            return "redirect:/main.html";
        }else {
            model.addAttribute("msg","用户名或秘密错误!");
            return "index";
        }
    }

    @RequestMapping("/user/logout")
    public String Logout(HttpSession session){
        session.invalidate();//清除session
        return "redirect:/index.html";
    }

}

MyMvcConfig

@Configuration//配置类
public class MyMvcConfig implements WebMvcConfigurer {
    @Override
    public void addViewControllers(ViewControllerRegistry registry) {
        registry.addViewController("/").setViewName("index");
        registry.addViewController("/index.html").setViewName("index");
        registry.addViewController("/main.html").setViewName("dashboard");
    }

    //自定义的登录拦截器
    @Override
    public void addInterceptors(InterceptorRegistry registry) {
        registry.addInterceptor(new LoginHandlerInterceptor())
                .addPathPatterns("/**")
                .excludePathPatterns("/index.html","/","/user/login","/asserts/**");
                //排除首页请求,登录请求,和静态资源
    }
}

LoginHandlerInterceptor

//登录拦截器
public class LoginHandlerInterceptor implements HandlerInterceptor {
    @Override
    public boolean preHandle(HttpServletRequest request, HttpServletResponse response, Object handler) throws Exception {
        //登录成功后有用户的session
        Object loginUser = request.getSession().getAttribute("loginUser");
        if (loginUser==null){
            request.setAttribute("msg","没有权限,请先登录!");
            request.getRequestDispatcher("/index.html").forward(request,response);
            return false;
        }
        else {
            return true;
        }
    }
}

Spring Security

代码实例

1、导入依赖
<dependency>
    <groupId>org.springframework.boot</groupId>
    <artifactId>spring-boot-starter-security</artifactId>
</dependency>

2、Controller
@RestController
public class MyController {

    @RequestMapping("/login-success")
    public String login(){
        return "登录成功";
    }

    @RequestMapping("/r/r1")
    public String r1(){
        return "访问R1";
    }
    @RequestMapping("/r/r2")
    public String r2(){
        return "访问R2";
    }
}

3、WebConfig
@Configuration
public class WebConfig implements WebMvcConfigurer {
    @Override
    public void addViewControllers(ViewControllerRegistry registry) {
        registry.addViewController("/").setViewName("index");
        registry.addViewController("/index.html").setViewName("index");
        registry.addViewController("/main").setViewName("dashboard");
    }

    //自定义的国际化组件生效
    @Bean("localeResolver")//必须是这个名字
    public LocaleResolver localeResolver(){
        return new MyLocaleResolver();
    }
}
4、SecurityConfig
@EnableWebSecurity
public class MySecurityConfig extends WebSecurityConfigurerAdapter {
    @Override
    protected void configure(HttpSecurity http) throws Exception {
        http.formLogin()
                .loginPage("/")
                .loginProcessingUrl("/login")
                .usernameParameter("username")
                .passwordParameter("password")
                .defaultSuccessUrl("/main");
        http.logout()
                .logoutSuccessUrl("/");
        http.csrf().disable();//关闭csrf功能:跨站请求伪造,默认只能通过post方式提交logout请求
        http.rememberMe()
                .rememberMeParameter("remember");//定制记住我功能name参数

        http.authorizeRequests()
                .antMatchers("/","/index.html").permitAll()
                .antMatchers("/asserts/**").permitAll()
                .antMatchers("/r/r1").hasAuthority("p1")
                .antMatchers("/r/r2").hasAuthority("p2")
                .anyRequest().authenticated();
    }

    @Override
    protected UserDetailsService userDetailsService() {
        return new MyUserDetailsService();
    }

    @Bean
    public PasswordEncoder passwordEncoder(){
        return new BCryptPasswordEncoder();
    }
}
5、UserDetailService
@Service
public class MyUserDetailsService implements UserDetailsService {
    @Override
    public UserDetails loadUserByUsername(String s) throws UsernameNotFoundException {
        return User.withUsername("zhiyu").password("$2a$10$qBRqkZU6hWHLnblhUSqEl.v2jhM22rXgv8y3VrFZumXd7WKjtIWlW").authorities("p1").build();
    }
}

工作原理

1、结构总览

Spring Security所解决的问题就是安全访问控制,而安全访问控制功能其实就是对所有进入系统的请求进行拦截, 校验每个请求是否能够访问它所期望的资源。根据前边知识的学习,可以通过Filter或AOP等技术来实现,Spring Security对Web资源的保护是靠Filter实现的,所以从这个Filter来入手,逐步深入Spring Security原理。

当初始化Spring Security时,会创建一个名为 SpringSecurityFilterChain 的Servlet过滤器,类型为 org.springframework.security.web.FilterChainProxy,它实现了javax.servlet.Filter,因此外部的请求会经过此 类,下图是Spring Security过虑器链结构图:

82FE93DD-46C8-402E-9ED0-E59F62CDAB76

FilterChainProxy 是一个代理,真正起作用的是FilterChainProxy中SecurityFilterChain所包含的各个Filter,同时 这些Filter作为Bean被Spring管理,它们是Spring Security核心,各有各的职责,但他们并不直接处理用户的认证,也不直接处理用户的授权,而是把它们交给了认证管理器(AuthenticationManager)和决策管理器 (AccessDecisionManager)进行处理,下图是FilterChainProxy相关类的UML图示。

58DB6F67-0CDE-419A-9331-24F14696404E

spring Security功能的实现主要是由一系列过滤器链相互配合完成。

7FA303AA-6A16-4521-80E7-0EAF9FE3E272

下面介绍过滤器链中主要的几个过滤器及其作用:

SecurityContextPersistenceFilter 这个Filter是整个拦截过程的入口和出口(也就是第一个和最后一个拦截器),会在请求开始时从配置好的 SecurityContextRepository 中获取 SecurityContext,然后把它设置给 SecurityContextHolder。在请求完成后将 SecurityContextHolder 持有的 SecurityContext 再保存到配置好 的 SecurityContextRepository,同时清除 securityContextHolder 所持有的 SecurityContext;

UsernamePasswordAuthenticationFilter 用于处理来自表单提交的认证。该表单必须提供对应的用户名和密 码,其内部还有登录成功或失败后进行处理的 AuthenticationSuccessHandler 和 AuthenticationFailureHandler,这些都可以根据需求做相关改变;

FilterSecurityInterceptor 是用于保护web资源的,使用AccessDecisionManager对当前用户进行授权访问,前 面已经详细介绍过了;

ExceptionTranslationFilter 能够捕获来自 FilterChain 所有的异常,并进行处理。但是它只会处理两类异常: AuthenticationException 和 AccessDeniedException,其它的异常它会继续抛出。

2、认证流程

D061FF70-C725-4602-B5C8-75DE53FFC3DB

让我们仔细分析认证过程:

  1. 用户提交用户名、密码被SecurityFilterChain中的 UsernamePasswordAuthenticationFilter 过滤器获取到, 封装为请求Authentication,通常情况下是UsernamePasswordAuthenticationToken这个实现类。

  2. 然后过滤器将Authentication提交至认证管理器(AuthenticationManager)进行认证

  3. 认证成功后, AuthenticationManager 身份管理器返回一个被填充满了信息的(包括上面提到的权限信息, 身份信息,细节信息,但密码通常会被移除) Authentication 实例。

  4. SecurityContextHolder 安全上下文容器将第3步填充了信息的 Authentication ,通过SecurityContextHolder.getContext().setAuthentication(…)方法,设置到其中。

可以看出AuthenticationManager接口(认证管理器)是认证相关的核心接口,也是发起认证的出发点,它 的实现类为ProviderManager。而Spring Security支持多种认证方式,因此ProviderManager维护着一个 List 列表,存放多种认证方式,最终实际的认证工作是由 AuthenticationProvider完成的。咱们知道web表单的对应的AuthenticationProvider实现类为 DaoAuthenticationProvider,它的内部又维护着一个UserDetailsService负责UserDetails的获取。最终 AuthenticationProvider将UserDetails填充至Authentication。

3、密码加密
@Test
public void Test(){

    //加密
    String hashpw = BCrypt.hashpw("123", BCrypt.gensalt());
    System.out.println(hashpw);

    //校验
    boolean checkpw = BCrypt.checkpw("123", "$2a$10$8WiIFMmuxPxy1LEJApbKc.uOowHEcRL50etHxkebaObwBwEf7X5ii");
    System.out.println(checkpw);
}
//密码加密
@Bean
public PasswordEncoder passwordEncoder(){
    return new BCryptPasswordEncoder();
}
4、授权流程

24C17681-1755-4550-999C-6ABB5D5EA08A

授权决策

AccessDecisionManager采用投票的方式来确定是否能够访问受保护资源。

AccessDecisionVoter是一个接口,其中定义有三个方法,具体结构如下所示。

public interface AccessDecisionVoter<S> { int ACCESS_GRANTED = 1; int ACCESS_ABSTAIN = 0; int ACCESS_DENIED = ‐1;

boolean supports(ConfigAttribute var1);

boolean supports(Class<?> var1);

int vote(Authentication var1, S var2, Collection<ConfigAttribute> var3);

}

vote()方法的返回结果会是AccessDecisionVoter中定义的三个常量之一。ACCESS_GRANTED表示同意, ACCESS_DENIED表示拒绝,ACCESS_ABSTAIN表示弃权。如果一个AccessDecisionVoter不能判定当前 Authentication是否拥有访问对应受保护对象的权限,则其vote()方法的返回值应当为弃权ACCESS_ABSTAIN。

Spring Security内置了三个基于投票的AccessDecisionManager实现类如下,它们分别是 AffirmativeBased、ConsensusBased和UnanimousBased。

AffirmativeBased的逻辑是:

  1. 只要有AccessDecisionVoter的投票为ACCESS_GRANTED则同意用户进行访问;

  2. 如果全部弃权也表示通过;

  3. 如果没有一个人投赞成票,但是有人投反对票,则将抛出AccessDeniedException。

    Spring security默认使用的是AffirmativeBased。

ConsensusBased的逻辑是:

  1. 如果赞成票多于反对票则表示通过。

  2. 反过来,如果反对票多于赞成票则将抛出AccessDeniedException。

  3. 如果赞成票与反对票相同且不等于0,并且属性allowIfEqualGrantedDeniedDecisions的值为true,则表 示通过,否则将抛出异常AccessDeniedException。参数allowIfEqualGrantedDeniedDecisions的值默认为true。

  4. 如果所有的AccessDecisionVoter都弃权了,则将视参数allowIfAllAbstainDecisions的值而定,如果该值 为true则表示通过,否则将抛出异常AccessDeniedException。参数allowIfAllAbstainDecisions的值默认为false。

UnanimousBased的逻辑与另外两种实现有点不一样,另外两种会一次性把受保护对象的配置属性全部传递 给AccessDecisionVoter进行投票,而UnanimousBased会一次只传递一个ConfigAttribute给 AccessDecisionVoter进行投票。这也就意味着如果我们的AccessDecisionVoter的逻辑是只要传递进来的 ConfigAttribute中有一个能够匹配则投赞成票,但是放到UnanimousBased中其投票结果就不一定是赞成了。 UnanimousBased的逻辑具体来说是这样的:

  1. 如果受保护对象配置的某一个ConfigAttribute被任意的AccessDecisionVoter反对了,则将抛出 AccessDeniedException。

  2. 如果没有反对票,但是有赞成票,则表示通过。

  3. 如果全部弃权了,则将视参数allowIfAllAbstainDecisions的值而定,true则通过,false则抛出 AccessDeniedException。

5、基于Web的授权API

规则的顺序是重要的,更具体的规则应该先写.现在以/ admin开始的所有内容都需要具有ADMIN角色的身份验证用 户,即使是/ admin / login路径(因为/ admin / login已经被/ admin / **规则匹配,因此第二个规则被忽略)。

.antMatchers("/admin/**").hasRole("ADMIN") .antMatchers("/admin/login").permitAll()

因此,登录页面的规则应该在/ admin / **规则之前,例如。

.antMatchers("/admin/login").permitAll() .antMatchers("/admin/**").hasRole("ADMIN")

保护URL常用的方法有:

  • authenticated() 保护URL,需要用户登录

  • permitAll()指定URL无需保护,一般应用与静态资源文件

  • hasRole(String role)限制单个角色访问,角色将被增加 “ROLE_” 。所以”ADMIN” 将和 “ROLE_ADMIN”进行比较。

  • hasAuthority(String authority) 限制单个权限访问

  • hasAnyRole(String… roles)允许多个角色访问

  • hasAnyAuthority(String… authorities) 允许多个权限访问

  • access(String attribute)该方法使用 SpEL表达式, 所以可以创建复杂的限制

  • hasIpAddress(String ipaddressExpression) 限制IP地址或子网

6、基于方法的授权API

从Spring Security2.0版 本开始,它支持服务层方法的安全性的支持。

开启方法授权API

@EnableGlobalMethodSecurity(prePostEnabled = true,securedEnabled = true)
public class MySecurityConfig extends WebSecurityConfigurerAdapter {
  • @PreAuthorize:在方法执行前拦截

    @RestController
    public class MyController {
        @RequestMapping("/r/r1")
        @PreAuthorize("hasAuthority('p1')")
        public String r1(){
            return "访问R1";
        }
    }
    
  • PostAuthorize:在方法执行后拦截

    使用方法同@PreAuthorize一样

一些🌰:

public interface BankService {
  @PreAuthorize("isAnonymous()")
  public Account readAccount(Long id);

  @PreAuthorize("isAnonymous()")
  public Account[] findAccounts();

  @PreAuthorize("hasAuthority('p_transfer') and hasAuthority('p_read_account')") 	 public Account post(Account account, double amount); 
}

以上配置标明readAccount、findAccounts方法可匿名访问,post方法需要同时拥有p_transfer和p_read_account 权限才能访问

出现的问题

1、静态资源加载问题

这是我的静态资源目录

image-20200918110248887

解决方案:给静态资源路径设置成允许所有人访问

@Override
protected void configure(HttpSecurity http) throws Exception {
    http.authorizeRequests()
            .antMatchers("/asserts/**").permitAll()
            .anyRequest().authenticated();
}

JWT

基本原理

JWT令牌由三部分组成,每部分中间使用点(.)分隔,比如:xxxxx.yyyyy.zzzzz

  • Header

    头部包括令牌的类型(即JWT)及使用的哈希算法(如HMAC SHA256或RSA)

    一个🌰如下: 下边是Header部分的内容

    注意⚠️:这是明文状态下的Header

    {
    	"alg": "HS256", "typ": "JWT"
    }
    

    将上边的内容使用Base64Url编码,得到一个字符串就是JWT令牌的第一部分。

  • payload

    第二部分是负载,内容也是一个json对象,它是存放有效信息的地方,它可以存放jwt提供的现成字段,比如:iss(签发者),exp(过期时间戳), sub(面向的用户)等,也可自定义字段。

    此部分不建议存放敏感信息,因为此部分可以解码还原原始内容。 最后将第二部分负载使用Base64Url编码,得到一个字符串就是JWT令牌的第二部分。

    一个🌰如下:

    {
    	"sub": "1234567890",
      "name": "456", 
      "admin": true
    }
    
  • Signature

    第三部分是签名,此部分用于防止jwt内容被篡改。

    这个部分使用base64url将前两部分进行编码,编码后使用点(.)连接组成字符串,最后使用header中声明的签名算法进行签名。

使用方式

1、简单测试

//获取令牌
@Test
public void TestJWT(){
    HashMap<String, Object> map = new HashMap<>();//header可不写

    Calendar instance = Calendar.getInstance();
    instance.add(Calendar.HOUR,20);

    String token = JWT.create()
            .withHeader(map)//header
            .withClaim("userId", 1) //payload
            .withClaim("userName", "zhiyu")
            .withExpiresAt(instance.getTime())//指定令牌的过期时间(时间戳)
            .sign(Algorithm.HMAC256("QWER"));//签名

    System.out.println(token);
}

//验证令牌
@Test
public void TestVerify(){
    //创建验证对象
    JWTVerifier jwtVerifier = JWT.require(Algorithm.HMAC256("QWER")).build();
    DecodedJWT verify = jwtVerifier.verify("eyJ0eXAiOiJKV1QiLCJhbGciOiJIUzI1NiJ9.eyJ1c2VyTmFtZSI6InpoaXl1IiwiZXhwIjoxNjAwNDg3MTg3LCJ1c2VySWQiOjF9.nvoXFwUjzeFxQ9lyrYYxtxARldb5ZnZ04vNn8JIaht8");
    //DecodedJWT:jwt的解码信息对象
    System.out.println(verify.getClaim("userId").asInt());
    System.out.println(verify.getClaim("userName").asString());
    System.out.println(verify.getExpiresAt());//过期时间
}

2、封装工具类

public class JWTUtils {

    private static final String SALT = "i_am_token";

    /**
     * 生成Token
     */
    public static String getToken(Map<String,String> map){
        Calendar instance = Calendar.getInstance();
        instance.add(Calendar.DATE,7);//7天过期

        JWTCreator.Builder builder = JWT.create();//创建Jwt builder

        //放入payload
        map.forEach((k,v) -> {
            builder.withClaim(k,v);
        });

        String token = builder
                .withExpiresAt(instance.getTime())//指定令牌的过期时间(时间戳)
                .sign(Algorithm.HMAC256(SALT));
        return token;
    }

    /**
     * 验证Token
     */
    public static void Verify(String token){
        JWT.require(Algorithm.HMAC256(SALT)).build().verify(token);
    }

    /**
     * 获取token信息
     */
    public static DecodedJWT getTokenInfo(String token) {
        return JWT.require(Algorithm.HMAC256(SALT)).build().verify(token);
    }
}