SpringSecurity详解

认证+授权代码实现

Spring Security是 一种基于 Spring AOP 和 Servlet 过滤器的安全框架。它提供全面的安全性解决方案,同时在 Web 请求级和方法调用级处理身份确认和授权。

有关认证和授权的理论知识,之前有写过相关博客。了解权限管理

一、SpringSceurity工作流程

网上找一张图,觉得画的挺好的,比较容易理解。不然换的是源码流程图很难去理解。

图片地址 : 地址 可以单机放大看更加清楚

要想理解这张图建议看下这篇博客,因为这张图中需要自定义的My...类,在文章中都有说明,所以更好理解点。

Spring Boot Security 详解


二、认证+授权代码

这里只展示一些核心代码,具体完整代码放在github上。

1、UserDetails接口

Security 中的用户接口,我们自定义用户类要实现该接口, 用于向security中注入当前用户的姓名密码,和拥有的角色。同时也包含一些其它信息,比如当前用户是否过期,

账号是否锁定等等。

自己定义User实现这个接口

public class User implements UserDetails {
    private String username;
    private String password;
    private List<Role> roles;
    /**
     * 获取用户名
     */
    @Override
    public String getUsername() {
        return username;
    }
    /**
     * 获取密码
     */
    @Override
    public String getPassword() {
        return password;
    }
    /**
     * 用户的权限集, 默认需要添加ROLE_ 前缀
     */
    @Override
    @JsonIgnore
    public List<GrantedAuthority> getAuthorities() {
        List<GrantedAuthority> authorities = new ArrayList<>();
        for (Role role : roles) {
            authorities.add(new SimpleGrantedAuthority("ROLE_" + role.getName()));
        }
        return authorities;
    }
    /**
     * 账户是否过期
     */
    @Override
    @JsonIgnore
    public boolean isAccountNonExpired() {
        return true;
    }
    /**
     * 账户是否锁定
     */
    @Override
    @JsonIgnore
    public boolean isAccountNonLocked() {
        return true;
    }
    /**
     * 凭证是否过期
     */
    @Override
    @JsonIgnore
    public boolean isCredentialsNonExpired() {
        return true;
    }
    /**
     * 用户是否可用
     */
    @Override
    public boolean isEnabled() {
        return true;
    }  
}

2、UserDetailsService

Security 中的用户 Service,自定义用户服务类需要实现该接口。这个接口只有一个方法需要我们去实现,那就是通过用户名去获取用户信息。这里也是和数据库交互获取

用户认证和授权信息的地方。

@Service
@Slf4j
public class UserService implements UserDetailsService {

    @Override
    public UserDetails loadUserByUsername(String userName) throws UsernameNotFoundException {
        //TODO 正常应该查询数据库获取用户和用户的权限
//        User user = userMapper.loadUserByUsername(userName);
//        List<Role> roles = rolesMapper.getRolesByUid(user.getId());
//        user.setRoles(roles);
        log.info("登陆用户名: {}", userName);
        //通过用户名查询到的密码 密码肯定是加密过的 这里明文密码是 123456
        String password = "e10adc3949ba59abbe56e057f20f883e";
        //用户对应权限
        List<Role> roles = Lists.newArrayList(new Role(1L, "教师"), new Role(2L, "学生"));
        User user = new User(userName, password, roles);
        return user;
    }
}

注意 这里的明文密码是 123456,也就是用户输入这个才能完成认证。授权的话当前用户有两个角色 教师 和 学生。在下面测试的时候会用到。

3、WebSecurityConfigurerAdapter

它是Spring Security的Java 配置类。创建类SecurityConfiguration继承 WebSecurityConfigurerAdapter,来对我们应用中所有的安全相关的事项(

所有url,验证用户名密码,表单重定向等)进行控制。

@Configuration
public class WebSecurityConfig extends WebSecurityConfigurerAdapter {

    /**
     * 1、配置的是认证信息, AuthenticationManagerBuilder 这个类,就是AuthenticationManager的建造者, 我们只需要向这个类中, 配置用户信息,
     *    就能生成对应的AuthenticationManager, 这个类也提过,是用户身份的管理者, 是认证的入口, 因此,我们需要通过这个配置,想security提供真实的用户身份。
     */
    @Override
    protected void configure(AuthenticationManagerBuilder auth) throws Exception {
    }
    /**
     * 2、配置Security的认证策略, 每个模块配置使用and结尾。这个也是最复杂的
     */
    @Override
    protected void configure(HttpSecurity http) throws Exception {
    }
    /**
     * 3、这个配置方法用于配置静态资源的处理方式,可使用 Ant 匹配规则。就是可以不用认证就可以直接访问的接口
     */
    @Override
    public void configure(WebSecurity web) throws Exception {
    }
}

完整示例

@Configuration
public class WebSecurityConfig extends WebSecurityConfigurerAdapter {

    @Autowired
    private UserService userService;
    /**
     * 密码验证器
     */
    @Autowired
    private PassWordEncorder passWordEncorder;
    /**
     * 成功处理器
     */
    @Autowired
    private AuthenctiationSuccessHandler authenctiationSuccessHandler;

    /**
     * 失败处理器
     */
   @Autowired
   private AuthenctiationFailHandler authenctiationFailHandler;
   /**
    * 向Security注入用户信息
    */
    @Override
    protected void configure(AuthenticationManagerBuilder auth) throws Exception {
        auth.userDetailsService(userService).passwordEncoder(passWordEncorder);
    }
    /**
     * 配置规则
     */
    @Override
    protected void configure(HttpSecurity http) throws Exception {
        //开启登陆配置
        http.authorizeRequests()
                // 登录之后就能访问
                .antMatchers("/no-authorize").authenticated()
                // 登陆后 需要校长角色权限
                .antMatchers("/need-authorize").hasRole("校长")
                // 其他的路径都是登录后即可访问
                .anyRequest().authenticated()
                .and().formLogin()
                // 定义登录页面,未登录时,访问一个需要登录之后才能访问的接口,会自动跳转到该页面
                .loginPage("/login_page")
                //登录成功的处理器
                .successHandler(authenctiationSuccessHandler)
                //登录失败的处理器
                .failureHandler(authenctiationFailHandler)
                // 登录处理接口
                .loginProcessingUrl("/login")
                // 定义登录时,用户名的 key,默认为 username
                .usernameParameter("username")
                //定义登录时,用户密码的 key,默认为 password
                .passwordParameter("password").permitAll()
                .and().logout()
                ////和表单登录相关的接口统统都直接通过
                .permitAll()
                .and().csrf().disable().exceptionHandling().accessDeniedHandler(getAccessDeniedHandler());
    }

    /**
     * 对于/static/  下的路径都不用认证
     */
    @Override
    public void configure(WebSecurity web) throws Exception {
        web.ignoring().antMatchers("/no-login");
    }

    /**
     * 用户未认证异常拦截
     */
    @Bean
    AccessDeniedHandler getAccessDeniedHandler() {
        return new AuthenticationAccessDeniedHandler();
    }
}

注意 这里一共配置了三个路径用于测试。

1、/no-login 接口不需要认证就可以直接访问
2、/no-authorize 需要认证 但不需要授权就可以访问
3、/need-authorize 首先需要认证 认证通过还需要授权 这里需要校长的角色才可以访问该接口 但是我们测试用户只有教师和学生所以没有权限访问该接口

下面会针对这个个接口分别进行测试。


三、测试

1、接口提供

@RestController
public class TestController {

    /**
     * 1、不需要登陆就可以访问
     */
    @RequestMapping(value = "/no-login")
    public ServiceResponse noLogin() {
        return ServiceResponse.success("欢迎访问不需要登陆接口");
    }
    /**
     * 2、只登陆,不许认证接口
     */
    @RequestMapping(value = "/no-authorize")
    public ServiceResponse needAuthorize(){
        return ServiceResponse.success("登陆了 不用授权");
    }
    /**
     * 3、登陆 + 相关认证接口
     */
    @RequestMapping(value = "/need-authorize")
    public ServiceResponse noAuthorize() {
        return ServiceResponse.success("登陆+授权成功");
    }
    /**
     * @Description: 如果自动跳转到这个页面,说明用户未登录,返回相应的提示即可
     */
    @RequestMapping("/login_page")
    public ServiceResponse loginPage() {
        return  ServiceResponse.failure("001", "尚未登录,请登录!");
    }
}

2、未登录访问 no-login 和 no-authorize 接口

no-login接口

很明显没有登陆 请求该接口成功!

no-authorize接口

没有登陆访问失败,在上面配置了如果用户没有认证的话跳转到login_page接口,所以这里返回 '尚未登录,请登录!'

3、登陆后访问 no-authorize 和 need-authorize 接口

先登陆

根据上面配置登陆的路径为 /login 请求参数包括 username 和 password

注意 这里需要post请求。

no-authorize 接口

登陆就可以访问了。

need-authorize 接口

虽然登陆成功了,但是因为该接口需要校长角色,之前给该用户只配置了教师和学生的角色所以访问失败。

SpringSecurity(2)---记住我功能实现

SpringSecurity(2)---记住我功能实现

上一篇博客实现了认证+授权的基本功能,这里在这个基础上,添加一个 记住我的功能

上一篇博客地址:SpringSecurity(1)---认证+授权代码实现

说明:上一遍博客的 用户数据 和 用户关联角色 的信息是在代码里写死的,这篇将从mysql数据库中读取。

一、数据库建表

这里建了三种表

一般权限表有四张或者五张,这里有关 角色关联资源表 没有创建,角色和资源的关系依旧在代码里写死。

建表sql

/*创建用户表*/
CREATE TABLE `persistent_logins` (
  `username` varchar(64) NOT NULL,
  `series` varchar(64) NOT NULL,
  `token` varchar(64) NOT NULL,
  `last_used` timestamp NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
  PRIMARY KEY (`series`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8;

/*创建j角色表*/
CREATE TABLE `roles` (
  `id` int NOT NULL AUTO_INCREMENT,
  `name` varchar(32) DEFAULT NULL,
  PRIMARY KEY (`id`)
) ENGINE=InnoDB AUTO_INCREMENT=6 DEFAULT CHARSET=utf8;

/*创建用户关联角色表*/
CREATE TABLE `roles_user` (
  `id` int NOT NULL AUTO_INCREMENT,
  `rid` int DEFAULT '2',
  `uid` int DEFAULT NULL,
  PRIMARY KEY (`id`)
) ENGINE=InnoDB AUTO_INCREMENT=133 DEFAULT CHARSET=utf8;


/*这里密码对应的明文 还是123456*/
INSERT INTO `user` (`id`, `username`, `nickname`, `password`, `enabled`)
VALUES
	(1, '小小', '小小', 'e10adc3949ba59abbe56e057f20f883e', 1);

/*三种角色*/
INSERT INTO `roles` (`id`, `name`)
VALUES
	(1, '校长'),
	(2, '教师'),
	(3, '学生');
	
/*小小用户关联了 教师和校长角色*/
INSERT INTO `roles_user` (`id`, `rid`, `uid`)
VALUES
	(1, 2, 1),
	(2, 3, 1);

说明:这里数据库只有一个用户

用户名 :小小

密码:123456

她所拥有的角色有两个 教师 和 学生


二、Spring Security的记住我功能基本原理

概念 记住我在登陆的时候都会被用户勾选,因为它方便地帮助用户减少了输入用户名和密码的次数,用户一旦勾选记住我功能那么 当服务器重启后依旧可以不用登陆就可以访问

Spring Security的“记住我”功能的基本原理流程图如下所示:

这里大致流程如下:

第一次登陆

用户请求的时候 remember-me参数为true 时,用户先进行 认证+授权过滤器。然后走记住我过滤器这里需要做两,这里主要做两件事。

1.将Token数据存入数据库 2.将token数据存入cookie中。

服务重启后

如果服务重启的话,那么之前的session信息已经不在了,但是cookie中的Token还是存在的。所以当用户重启后去访问需要认证的接口时,会先通过cookie中的Token

去数据库查询这条Token信息,如果存在那么在通过用户名去查询数据库获取当前用户的信息。


三、代码实现

因为上面项目已经完成了整个授权+认证的过程,那么这里就很简单添加一点点代码就可以了。

在WebSecurityConfig中添加一个Bean,配置完这个Bean就基本完成了 记住我 功能的开发,然后在将这个Bean设置到configure方法中即可。

    @Bean
    public PersistentTokenRepository tokenRepository() {
        JdbcTokenRepositoryImpl tokenRepository = new JdbcTokenRepositoryImpl();
        tokenRepository.setDataSource(dataSource);
        //tokenRepository.setCreateTableOnStartup(true);
        return tokenRepository;
    }

上面的代码 tokenRepository.setCreateTableOnStartup(true) ;是自动创建Token存到数据库时候所需要的表,这行代码只能运行一次,如果重新启动数据库,

必须删除这行代码,否则将报错,因为在第一次启动的时候已经创建了表,不能重复创建。保险起见我们还是注释掉这段代码,手动建这张表。

CREATE TABLE `persistent_logins` (
  `username` varchar(64) NOT NULL,
  `series` varchar(64) NOT NULL,
  `token` varchar(64) NOT NULL,
  `last_used` timestamp NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
  PRIMARY KEY (`series`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8;

在配置里再加上这些就可以了。

 

四、测试

主要测试两个点地方,

1、当我登陆时选择记住我功能,看下数据库persistent_logins是否有一条token记录
2、当使用记住我功能后,关闭服务器在重启服务器,不再登陆直接访问需要认证的接口,看是否能够访问成功。

1、首次登陆

我们在看数据库token表

很明显新增了一条token数据。

2、重启服务器

这个时候我们重启服务器访问需要认证的接口

发现就算重启也不需要重启登陆就可以反问需要认证的接口。


五、源码分析

同样这里也分为两部分 1、第一次登陆源码流程。 2、重启后未认证再去访问需要认证的接口源码流程。

1、首次登陆源码流程

第一步

当用户发送登录请求的时候,首先到达的是UsernamePasswordAuthenticationFilter这个过滤器,然后执行attemptAuthentication方法的代码,代码如下图所示:

 public Authentication attemptAuthentication(HttpServletRequest request, HttpServletResponse response) throws AuthenticationException {
 //从这里可以看出登陆需要post提交
        if (this.postOnly && !request.getMethod().equals("POST")) {
            throw new AuthenticationServiceException("Authentication method not supported: " + request.getMethod());
        } else {
            String username = this.obtainUsername(request);
            String password = this.obtainPassword(request);
            if (username == null) {
                username = "";
            }

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

            username = username.trim();
            UsernamePasswordAuthenticationToken authRequest = new UsernamePasswordAuthenticationToken(username, password);
            this.setDetails(request, authRequest);
            return this.getAuthenticationManager().authenticate(authRequest);
        }
    }

之后所走的流程就是 ProviderManager的authenticate方法 ,之后再走AbstractUserDetailsAuthenticationProvider的authenticate方法,再走DaoAuthenticationProvider的方法retrieveUser方法

 protected final UserDetails retrieveUser(String username, UsernamePasswordAuthenticationToken authentication) throws AuthenticationException {
        this.prepareTimingAttackProtection();

        try {
            //这里就走我们自定义的获取用户认证和授权信息的代码了
            UserDetails loadedUser = this.getUserDetailsService().loadUserByUsername(username);
            if (loadedUser == null) {
                throw new InternalAuthenticationServiceException("UserDetailsService returned null, which is an interface contract violation");
            } else {
                return loadedUser;
            }
        } catch (UsernameNotFoundException var4) {
            this.mitigateAgainstTimingAttack(authentication);
            throw var4;
        } catch (InternalAuthenticationServiceException var5) {
            throw var5;
        } catch (Exception var6) {
            throw new InternalAuthenticationServiceException(var6.getMessage(), var6);
        }
    }

这样一来,认证的流程就已经走完了。那就要走记住我功能的过滤器了。

第二步

验证成功之后,将进入AbstractAuthenticationProcessingFilter 类的successfulAuthentication的方法中,首先将认证信息通过代码
SecurityContextHolder.getContext().setAuthentication(authResult);将认证信息存入到session中,紧接着这个方法中就调用了rememberMeServices的loginSuccess方法

 protected void successfulAuthentication(HttpServletRequest request, HttpServletResponse response, FilterChain chain, Authentication authResult) throws IOException, ServletException {
        if (this.logger.isDebugEnabled()) {
            this.logger.debug("Authentication success. Updating SecurityContextHolder to contain: " + authResult);
        }

        SecurityContextHolder.getContext().setAuthentication(authResult);
        //记住我
        this.rememberMeServices.loginSuccess(request, response, authResult);
        if (this.eventPublisher != null) {
            this.eventPublisher.publishEvent(new InteractiveAuthenticationSuccessEvent(authResult, this.getClass()));
        }

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

再走PersistentTokenBasedRememberMeServices的onLoginSuccess方法

    protected void onLoginSuccess(HttpServletRequest request, HttpServletResponse response, Authentication successfulAuthentication) {
        String username = successfulAuthentication.getName();
        this.logger.debug("Creating new persistent login for user " + username);
        PersistentRememberMeToken persistentToken = new PersistentRememberMeToken(username, this.generateSeriesData(), this.generateTokenData(), new Date());

        try {
            //这里就是关键的两步 1、将token存入到数据库 2、将token存入cookie中
            this.tokenRepository.createNewToken(persistentToken);
            this.addCookie(persistentToken, request, response);
        } catch (Exception var7) {
            this.logger.error("Failed to save persistent token ", var7);
        }

    }

这个方法中调用了tokenRepository来创建Token并存到数据库中,且将Token写回到了Cookie中。到这里,基本的登录过程基本完成,生成了Token存到了数据库,

且写回到了Cookie中。

2、第二次访问

重启项目,这时候服务器端的session已经不存在了,但是第一次登录成功已经将Token写到了数据库和Cookie中,直接访问一个服务,并且不输入用户名和密码。

第一步

首先进入到了RememberMeAuthenticationFilter的doFilter方法中,这个方法首先检查在session中是否存在已经验证过的Authentication了,如果为空,就进行下面的

RememberMe的验证代码,比如调用rememberMeServices的autoLogin方法,代码如下:

    public void doFilter(ServletRequest req, ServletResponse res, FilterChain chain) throws IOException, ServletException {
        HttpServletRequest request = (HttpServletRequest)req;
        HttpServletResponse response = (HttpServletResponse)res;
        if (SecurityContextHolder.getContext().getAuthentication() == null) {
            //走记住我流程
            Authentication rememberMeAuth = this.rememberMeServices.autoLogin(request, response);
            //省略不重要的代码
            chain.doFilter(request, response);
        } else {
            chain.doFilter(request, response);
        }
    }

我们在看this.rememberMeServices.autoLogin(request, response)方法。最终实现在AbstractRememberMeServices的autoLogin方法

    public final Authentication autoLogin(HttpServletRequest request, HttpServletResponse response) {
        //1、获取token
        String rememberMeCookie = this.extractRememberMeCookie(request);
        if (rememberMeCookie == null) {
            return null;
        } else {
    
                UserDetails user = null;
                try {
                    String[] cookieTokens = this.decodeCookie(rememberMeCookie);
                    //这步是关键
                    user = this.processAutoLoginCookie(cookieTokens, request, response);
                    this.userDetailsChecker.check(user);
                    this.logger.debug("Remember-me cookie accepted");
                    return this.createSuccessfulAuthentication(request, user);
                } catch (CookieTheftException var6) {
                    this.cancelCookie(request, response);
                    throw var6;
                } 
                this.cancelCookie(request, response);
                return null;
            }
        }
    }

我们在看 this.processAutoLoginCookie(cookieTokens, request, response);在PersistentTokenBasedRememberMeServices中实现,到这一步就已经很明白了

 protected UserDetails processAutoLoginCookie(String[] cookieTokens, HttpServletRequest request, HttpServletResponse response) {
        if (cookieTokens.length != 2) {
            throw new InvalidCookieException("Cookie token did not contain 2 tokens, but contained '" + Arrays.asList(cookieTokens) + "'");
        } else {
            String presentedSeries = cookieTokens[0];
            String presentedToken = cookieTokens[1];
            //1、去token表中查询token
            PersistentRememberMeToken token = this.tokenRepository.getTokenForSeries(presentedSeries);
            if (token == null) {
                throw new RememberMeAuthenticationException("No persistent token found for series id: " + presentedSeries);
                //2校验数据
            } else if (!presentedToken.equals(token.getTokenValue())) {
                this.tokenRepository.removeUserTokens(token.getUsername());
                throw new CookieTheftException(this.messages.getMessage("PersistentTokenBasedRememberMeServices.cookieStolen", "Invalid remember-me token (Series/token) mismatch. Implies previous cookie theft attack."));
                //3、查看token是否过期
            } else if (token.getDate().getTime() + (long)this.getTokenValiditySeconds() * 1000L < System.currentTimeMillis()) {
                throw new RememberMeAuthenticationException("Remember-me login has expired");
            } else {
                if (this.logger.isDebugEnabled()) {
                    this.logger.debug("Refreshing persistent login token for user '" + token.getUsername() + "', series '" + token.getSeries() + "'");
                }

                PersistentRememberMeToken newToken = new PersistentRememberMeToken(token.getUsername(), token.getSeries(), this.generateTokenData(), new Date());

                try {
                //4、更新这条token 没更新一次有效时间就都变成了之间设置的时间
                    this.tokenRepository.updateToken(newToken.getSeries(), newToken.getTokenValue(), newToken.getDate());
                    this.addCookie(newToken, request, response);
                } catch (Exception var9) {
                    this.logger.error("Failed to update token: ", var9);
                    throw new RememberMeAuthenticationException("Autologin failed due to data access problem");
                }
                 //5、这里拿着用户名 就又获取当前用户的认证和授权信息
                return this.getUserDetailsService().loadUserByUsername(token.getUsername());
            }
        }
    }

这样整个流程就完成了,我们可以看出源码的过程和上面图片展示的流程还是非常像的。

SpringSceurity(3)---图形验证码功能实现

SpringSceurity(3)---图形验证码功能实现

有关springSceurity之前有写过两篇文章:

1、SpringSecurity(1)---认证+授权代码实现

2、SpringSecurity(2)---记住我功能实现

这篇我们来讲图形验证码功能实现。

一、思路

我整理下springSceurity整合图形验证码的大致思路:

1、首先对于验证码本身而言,应该有三部分组成 1、存放验证码的背景图片 2、验证码 3、验证码的有效时间。

2、对于springSceurity而言,验证码的执行校验顺序肯定是在UsernamePasswordAuthenticationFilter之前的,因为如果验证码都不对,那么
根本都不需要验证账号密码。所以我们需要自定义一个验证码过滤器,并且配置在UsernamePasswordAuthenticationFilter之前执行。

3、对于获取验证码的接口,肯定是不需要进行认证限制的。

4、对于获取验证码的接口的时候,需要把该验证码信息+当前浏览器的SessonId绑定在一起存在Seesion中,为了后面校验的时候通过SessonId
去取这个验证码信息。

5、登陆请求接口,除了带上用户名和密码之外,还需要带上验证码信息。在进入验证码过滤器的时候,首先通过SessonId获取存在Sesson中的
验证码信息,拿到验证码信息之后首先还要校验该验证码是否在有效期内。之后再和当前登陆接口带来的验证码进行对比,如果一致,那么当前
验证码这一关就过了,就开始验证下一步账号和密码是否正确了。

整个流程大致就是这样。下面现在是具体代码,然后进行测试。


二、代码展示

这里只展示一些核心代码,具体完整项目会放到github上。

1、ImageCodeProperties

这个是一个bean实体,是一个图形验证码的默认配置。

@Data
public class ImageCodeProperties {
    /**
     * 验证码宽度
     */
    private int width = 67;
    /**
     * 验证码高度
     */
    private int height = 23;
    /**
     * 验证码长度
     */
    private int length = 4;
    /**
     * 验证码过期时间
     */
    private int expireIn = 60;
    /**
     * 需要验证码的请求url字符串,用英文逗号隔开
     */
    private String url = "/login";

}

2、ImageCode

这个是图片验证码的完整信息,也会将这个完整信息存放于Sesson中。

图片验证码信息 由三部分组成 :

1.图片信息(长、宽、背景色等等)。2.code就是真正的验证码,用来验证用。3.该验证码的有效时间。

@Data
@AllArgsConstructor
@NoArgsConstructor
public class ImageCode {

    private BufferedImage image;
    private String code;
    private LocalDateTime expireTime;

    public ImageCode(BufferedImage image, String code, int expireIn) {
        this.image = image;
        this.code = code;
        this.expireTime = LocalDateTime.now().plusSeconds(expireIn);
    }
    /**
     * 校验是否过期
     */
    public boolean isExpired() {
        return LocalDateTime.now().isAfter(expireTime);
    }
}

3、ValidateCodeGeneratorService

获取验证码的接口

public interface ValidateCodeGeneratorService {

    /**
     * 生成图片验证码
     *
     * @param request 请求
     * @return ImageCode实例对象
     */
    ImageCode generate(ServletWebRequest request);
}

4、ImageCodeGeneratorServiceImpl

获取图片验证码的接口的实现类

@Data
public class ImageCodeGeneratorServiceImpl implements ValidateCodeGeneratorService {

    private static final String IMAGE_WIDTH_NAME = "width";
    private static final String IMAGE_HEIGHT_NAME = "height";
    private static final Integer MAX_COLOR_VALUE = 255;

    private ImageCodeProperties imageCodeProperties;

    @Override
    public ImageCode generate(ServletWebRequest request) {
        //设置图片的宽度和高度
        int width = ServletRequestUtils.getIntParameter(request.getRequest(), IMAGE_WIDTH_NAME, imageCodeProperties.getWidth());
        int height = ServletRequestUtils.getIntParameter(request.getRequest(), IMAGE_HEIGHT_NAME, imageCodeProperties.getHeight());
        BufferedImage image = new BufferedImage(width, height, BufferedImage.TYPE_INT_RGB);
        Graphics g = image.getGraphics();
        //验证码随机数
        Random random = new Random();

        // 生成画布
        g.setColor(getRandColor(200, 250));
        g.fillRect(0, 0, width, height);
        g.setFont(new Font("Times New Roman", Font.ITALIC, 20));
        g.setColor(getRandColor(160, 200));
        for (int i = 0; i < 155; i++) {
            int x = random.nextInt(width);
            int y = random.nextInt(height);
            int xl = random.nextInt(12);
            int yl = random.nextInt(12);
            g.drawLine(x, y, x + xl, y + yl);
        }

        // 生成数字验证码
        StringBuilder sRand = new StringBuilder();
        for (int i = 0; i < imageCodeProperties.getLength(); i++) {
            String rand = String.valueOf(random.nextInt(10));
            sRand.append(rand);
            g.setColor(new Color(20 + random.nextInt(110), 20 + random.nextInt(110), 20 + random.nextInt(110)));
            g.drawString(rand, 13 * i + 6, 16);
        }

        g.dispose();
        //这样验证码的图片 、数字、有效期都有组装好了
        return new ImageCode(image, sRand.toString(), imageCodeProperties.getExpireIn());
    }

    /**
     * 生成随机背景条纹
     *
     * @param fc 前景色
     * @param bc 背景色
     * @return RGB颜色
     */
    private Color getRandColor(int fc, int bc) {
        Random random = new Random();
        if (fc > MAX_COLOR_VALUE) {
            fc = MAX_COLOR_VALUE;
        }
        if (bc > MAX_COLOR_VALUE) {
            bc = MAX_COLOR_VALUE;
        }
        int r = fc + random.nextInt(bc - fc);
        int g = fc + random.nextInt(bc - fc);
        int b = fc + random.nextInt(bc - fc);
        return new Color(r, g, b);
    }
}

5、ValidateCodeController

获取验证码的请求接口。

@RestController
public class ValidateCodeController {

    /**
     * 前缀
     */
   public static final String SESSION_KEY = "SESSION_KEY_IMAGE_CODE";
    private static final String FORMAT_NAME = "JPEG";
   @Autowired
    private ValidateCodeGeneratorService imageCodeGenerator;
    private SessionStrategy sessionStrategy = new HttpSessionSessionStrategy();

    @GetMapping("/code/image")
    public void createCode(HttpServletRequest request, HttpServletResponse response) throws IOException {
        // 第一步:根据请求生成一个图形验证码对象
        ImageCode imageCode = imageCodeGenerator.generate(new ServletWebRequest(request));
        // 第二步:将图形验证码对象存到session中,第一个参数可以从传入的请求中获取session
        sessionStrategy.setAttribute(new ServletRequestAttributes(request), SESSION_KEY, imageCode);
        // 第三步:将生成的图片写到接口的响应中
        ImageIO.write(imageCode.getImage(), FORMAT_NAME, response.getOutputStream());
    }

}

到这里,我们可用请求获取图片验证码的信息了。接下来我们就要登陆请求部分的代码。

6、ValidateCodeFilter

自定义过滤器,这里面才是核心的代码,首先继承OncePerRequestFilter(直接继承Filter也是可用的),实现InitializingBean是为了初始化一些初始数据。

这里走的逻辑就是把存在session中的图片验证码和当前请求的验证码进行比较,如果相同则放行,否则直接抛出异常

@Data
@Slf4j
@Component
public class ValidateCodeFilter extends OncePerRequestFilter implements InitializingBean {

    private static final String SUBMIT_FORM_DATA_PATH = "/login";

    /**
     * 失败处理器
     */
    @Autowired
    private AuthenctiationFailHandler authenctiationFailHandler;
    /**
     * 验证码属性类
     */
    @Autowired
    private ImageCodeProperties imageCodeProperties;
    
    /**
     * 存放需要走验证码请求url
     */
    private Set<String> urls = new HashSet<>();

    /**
     * 处理session工具类
     */
    private SessionStrategy sessionStrategy = new HttpSessionSessionStrategy();
    /**
     * 正则配置工具
     */
    private final AntPathMatcher antPathMatcher = new AntPathMatcher();
    /**
     * 在初始化bean的时候都会执行该方法
     */
    @Override
    public void afterPropertiesSet() throws ServletException {
        super.afterPropertiesSet();
        String[] configUrls = StringUtils.split(imageCodeProperties.getUrl(), ",");
        // 登录的链接是必须要进行验证码验证的
        urls.addAll(Arrays.asList(configUrls));
    }

    /**
     * 拦截请求进来的方法。
     */
    @Override
    protected void doFilterInternal(HttpServletRequest request, HttpServletResponse response, FilterChain filterChain) throws ServletException, IOException {
        boolean action = false;
        for (String url : urls) {
            // 如果实际访问的URL可以与用户在imageCodeProperties中url配置的相同,那么就进行验证码校验
            log.info("request.getRequestURI = {}",request.getRequestURI());
            if (antPathMatcher.match(url, request.getRequestURI())) {
                action = true;
            }
        }
        //说明需要校验
        if (action) {
            try {
                validate(new ServletWebRequest(request));
            } catch (ValidateCodeException e) {
                authenctiationFailHandler.onAuthenticationFailure(request, response, e);
                return;
            }
        }

        //进入下一个过滤器
        filterChain.doFilter(request, response);
    }

    /**
     * 验证码校验逻辑
     *
     */
    private void validate(ServletWebRequest request) throws ServletRequestBindingException {
        // 从session中获取图片验证码
        ImageCode imageCodeInSession = (ImageCode) sessionStrategy.getAttribute(request, ValidateCodeController.SESSION_KEY);

        // 从请求中获取用户填写的验证码
        String imageCodeInRequest = ServletRequestUtils.getStringParameter(request.getRequest(), "imageCode");
        if (StringUtils.isBlank(imageCodeInRequest)) {
            throw new ValidateCodeException("验证码不能为空");
        }
        if (null == imageCodeInSession) {
            throw new ValidateCodeException("验证码不存在");
        }
        if (imageCodeInSession.isExpired()) {
            sessionStrategy.removeAttribute(request, ValidateCodeController.SESSION_KEY);
            throw new ValidateCodeException("验证码已过期");
        }

        log.info("session中获取的验证码={},sessionId ={}",imageCodeInSession.getCode(),request.getSessionId());
        log.info("登陆操作传来的验证码={}",imageCodeInRequest);
        if (!StringUtils.equalsIgnoreCase(imageCodeInRequest, imageCodeInSession.getCode())) {
            throw new ValidateCodeException("验证码不匹配");
        }
        // 验证成功,删除session中的验证码
        sessionStrategy.removeAttribute(request, ValidateCodeController.SESSION_KEY);
    }
}

7、WebSecurityConfig

SpringSecurity的Java 配置类也需要做一点点改动。那就是需要设置ValidateCodeFilter要在UsernamePasswordAuthenticationFilter之前进行拦截过滤。

到这里整个图形验证码的功能就开发完成了,具体代码放在github上下面进行测试。


三、测试

说明下我这里懒的写前端相关代码了,所以直接用posman用请求来获取验证码,获取验证码之后再进行登陆操作。

1、获取验证码

这里验证码code为:1848

2、登陆成功

输入的验证码也是1848,显示登陆成功。

3、登陆失败

因为配置的时候图片验证码有效期为60秒,所以在我们获取验证码后,过60秒再去登陆,就能发现,验证码已过期。

整个验证码功能大致就是这样。

SpringSceurity(4)---短信验证码功能实现

SpringSceurity(4)---短信验证码功能实现

有关SpringSceurity系列之前有写文章

1、SpringSecurity(1)---认证+授权代码实现

2、SpringSecurity(2)---记住我功能实现

3、SpringSceurity(3)---图形验证码功能实现

一、思考

1、设计思路

在获取短信验证码功能和图形验证码还是有很多相似的地方,所以这里在设计获取短信验证的时候,将之前开发好的的图形验证码进一步整合、抽象与重构。

在获取验证码的时候,它们最大的不同在于: 图形验证码是通过接口返回获取給前端。而短信验证码而言是通过第三方API向我们手机推送

但是它们在登陆的时候就有很大的不同了,对于图形验证码而言验证通过之前就走 UsernamePasswordAuthenticationFilter 过滤器了开始校验用户名密码了。

但对于短信登陆而言,确实也需要先现在短信验证码是否通过,但是一旦通过他是不走 UsernamePasswordAuthenticationFilter,而是通过其它方式查询用户信息来校验

认证已经通过了。

这篇博客只写获取获取短信验证码的功能,不写通过短信验证码登陆的逻辑。

2、重构设计

这里才是最重要的,如何去设计和整合短信验证码和图形验证码的代码,是我们最应该思考的。如何将相似部分抽离出来,然后去实现不相同的部分。

整理后发现不同点主要在于

 1、获取验证码。因为对于图形验证码需要有个画布,而短信验证码并不需要,所以它们可以实现同一个接口,来完成不同的逻辑。
 2、发送验证码。对于图形验证码来讲只要把验证码返给前端就可以,而短信验证码而言是通过第三方API将验证码发到我们的手机上。
    所以这里也可以通过实现统一接口来具体实现不同的方法。

相同部分我可以通过抽象类来完成实现,不同部分可以通过具体的实现类来实现。

AbstractValidateCodeProcessorService 抽象类是用来实现两种验证码可以抽离的部分。ImageCodeProcessorServiceImpl 和

SmsCodeProcessorServiceImpl方法是来实现两种验证码不同的发送方式。

在简单看下时序图可能会更加明白点。

一个接口只有一个方法(processor)就是处理验证码,它其实需要做三件事。

 1、获取验证码。2、将验证码存入session。3、将验证码信息通过短信或者图形验证码发送出去。

首先讲生成获取验证码,这里有一个公共接口和两个实现类

对于保存验证码信息而言,可以在直接在 AbstractValidateCodeProcessorService抽象类来完成,都不需要去实现。

对发送验证码信息而言,只需要实现AbstractValidateCodeProcessorService抽象类的send发送验证码接口即可。

整个大致接口设计就是这样,具体的可以通过代码来展示。


二、代码实现

1、验证码属性

短信验证码和图形验证后包含属性有 code 和 expireTime,短信验证码只有这两个属性,而图形验证码还多一个BufferedImage实例对象属性,所以将共同属性进行抽取

,抽取为ValidateCode类,代码如下:

ValidateCode实体

@Data
@AllArgsConstructor
@NoArgsConstructor
public class ValidateCode {

    private String code;

    private LocalDateTime expireTime;

    public boolean isExpired() {
        return LocalDateTime.now().isAfter(expireTime);
    }

}

对于图形验证码而言,除了需要code和过期时间还需要图片的画布,所以继承ValidateCode之后再写自己属性

ImageCode实体

@Data
public class ImageCode extends ValidateCode {

    private BufferedImage image;

    public ImageCode(BufferedImage image, String code, LocalDateTime expireTime) {
        super(code, expireTime);
        this.image = image;
    }

    public ImageCode(BufferedImage image, String code, int expireIn) {
        super(code, LocalDateTime.now().plusSeconds(expireIn));
        this.image = image;
    }
}

对于短信验证码而言,暂时不需要添加自己的属性字段了。

SmsCode实体

public class SmsCode extends ValidateCode {

    public SmsCode(String code, LocalDateTime expireTime) {
        super(code, expireTime);
    }

    public SmsCode(String code, int expireIn) {
        super(code, LocalDateTime.now().plusSeconds(expireIn));
    }
}

2、ValidateCodeProcessor接口

ValidateCodeProcessor接口主要是完成 验证码的生成、保存与发送的完整流程,接口的主要设计如下所示:

ValidateCodeProcessorService接口

public interface ValidateCodeProcessorService {

    /**
     * 因为现在有两种验证码,所以存放到seesion的key不能一样,所以前缀+具体type
     */
    String SESSION_KEY_PREFIX = "SESSION_KEY_FOR_CODE_";
    /**
     * 通过也是 type+CODE_PROCESSOR获取对于的bean
     */
    String CODE_PROCESSOR = "CodeProcessorService";

    /**
     * 这个接口要做三件事
     * 1、获取验证码。
     * 2、将验证码存入session
     * 3、将验证码信息通过短信或者图形验证码发送出去。
     * (将spring-boot-security-study-03接口里的那三步进行里封装)
     *
     */
    void processor(ServletWebRequest request) throws Exception;

由于图片验证码和短信验证码的 生成和保存、发送等流程是固定的。所以这里写一个抽象类来实现ValidateCodeProcessor接口,来实现相似部分。

AbstractValidateCodeProcessorService抽象类

@Component
public abstract class AbstractValidateCodeProcessorService<C> implements ValidateCodeProcessorService {

    private static final String SEPARATOR = "/code/";

    /**
     * 操作session的工具集
     */
    private SessionStrategy sessionStrategy = new HttpSessionSessionStrategy();

    /**
     * 这是Spring的一个特性,就是在项目启动的时候会自动收集系统中 {@link ValidateCodeGeneratorService} 接口的实现类对象
     *
     * key为bean name
     */
    @Autowired
    private Map<String, ValidateCodeGeneratorService> validateCodeGeneratorMap;

    @Override
    public void processor(ServletWebRequest request) throws Exception {
        //第一件事
        C validateCode = generate(request);
        //第二件事
        save(request, validateCode);
        //第三件事
        send(request, validateCode);
    }

    /**
     * 生成验证码
     *
     */
    private C generate(ServletWebRequest request) {
        String type = getProcessorType(request);
        //这里 image+CodeGenerator = imgCodeGenerator 对应的就是ImageCodeGeneratorServiceService
        ValidateCodeGeneratorService validateCodeGenerator = validateCodeGeneratorMap.get(type.concat(ValidateCodeGeneratorService.CODE_GENERATOR));
        return (C) validateCodeGenerator.generate(request);
    }

    /**
     * 保存验证码到session中
     */
    private void save(ServletWebRequest request, C validateCode) {
        //这里也是封装了一下
        sessionStrategy.setAttribute(request, SESSION_KEY_PREFIX.concat(getProcessorType(request).toUpperCase()), validateCode);
    }

    /**
     * 发送验证码 (只有发送验证码是需要自己去实现的。)
     */
    protected abstract void send(ServletWebRequest request, C validateCode) throws Exception;

    /**
     * 获取请求URL中具体请求的验证码类型
     *
     */
    private String getProcessorType(ServletWebRequest request) {
        // 获取URI分割后的第二个片段 (/code/image 通过/code/ 切割后就只剩下 image
        return StringUtils.substringAfter(request.getRequest().getRequestURI(), SEPARATOR);
    }
}

简单说明

1、这里用到了Spring一个特性就是Map<String, ValidateCodeGeneratorService> validateCodeGeneratorMap 可以把ValidateCodeGeneratorService所以的实现类都放
到这个map中,key为bean的名称。

2、抽象类中实现了 ValidateCodeProcessor接口的processor方法,它主要是完成了验证码的创建、保存和发送的功能。

3、generate 方法根据传入的不同泛型而生成了特定的验证码。

4、save 方法是将生成的验证码实例对象存入到session中,两种验证码的存储方式一致,只是有个key不一致,所以代码也是通用的。

5、send 方法一个抽象方法,分别由ImageCodeProcessorService和SmsCodeProcessorService来具体实现,也是根据泛型来判断具体调用哪一个具体的实现类的send方法。

3、验证码的生成接口

上面说过验证的生成应该也是通过实现类

ValidateCodeGeneratorService

public interface ValidateCodeGeneratorService {

    /**
     * 这个常量也是用来 type+CodeGeneratorService获取对于bean对象
     */
    String CODE_GENERATOR = "CodeGeneratorService";

    /**
     * 生成验证码
     * 具体是图片验证码 还是短信验证码就需要对应的实现类
     */
    ValidateCode generate(ServletWebRequest request);
}

图形验证码具体实现类

mageCodeGeneratorServiceImpl

@Data
@Component("imageCodeGeneratorService")
public class ImageCodeGeneratorServiceImpl implements ValidateCodeGeneratorService {

    private static final String IMAGE_WIDTH_NAME = "width";
    private static final String IMAGE_HEIGHT_NAME = "height";
    private static final Integer MAX_COLOR_VALUE = 255;

    @Autowired
    private ValidateCodeProperties validateCodeProperties;

    @Override
    public ImageCode generate(ServletWebRequest request) {
        int width = ServletRequestUtils.getIntParameter(request.getRequest(), IMAGE_WIDTH_NAME, validateCodeProperties.getImage().getWidth());
        int height = ServletRequestUtils.getIntParameter(request.getRequest(), IMAGE_HEIGHT_NAME,validateCodeProperties.getImage().getHeight());
        BufferedImage image = new BufferedImage(width, height, BufferedImage.TYPE_INT_RGB);
        Graphics g = image.getGraphics();

        Random random = new Random();

        // 生成画布
        g.setColor(getRandColor(200, 250));
        g.fillRect(0, 0, width, height);
        g.setFont(new Font("Times New Roman", Font.ITALIC, 20));
        g.setColor(getRandColor(160, 200));
        for (int i = 0; i < 155; i++) {
            int x = random.nextInt(width);
            int y = random.nextInt(height);
            int xl = random.nextInt(12);
            int yl = random.nextInt(12);
            g.drawLine(x, y, x + xl, y + yl);
        }

        // 生成数字验证码
        StringBuilder sRand = new StringBuilder();
        for (int i = 0; i < validateCodeProperties.getImage().getLength(); i++) {
            String rand = String.valueOf(random.nextInt(10));
            sRand.append(rand);
            g.setColor(new Color(20 + random.nextInt(110), 20 + random.nextInt(110), 20 + random.nextInt(110)));
            g.drawString(rand, 13 * i + 6, 16);
        }

        g.dispose();

        return new ImageCode(image, sRand.toString(), validateCodeProperties.getImage().getExpireIn());
    }

    /**
     * 生成随机背景条纹
     *
     * @param fc 前景色
     * @param bc 背景色
     * @return RGB颜色
     */
    private Color getRandColor(int fc, int bc) {
        Random random = new Random();
        if (fc > MAX_COLOR_VALUE) {
            fc = MAX_COLOR_VALUE;
        }
        if (bc > MAX_COLOR_VALUE) {
            bc = MAX_COLOR_VALUE;
        }
        int r = fc + random.nextInt(bc - fc);
        int g = fc + random.nextInt(bc - fc);
        int b = fc + random.nextInt(bc - fc);
        return new Color(r, g, b);
    }
}

短信验证码具体实现类

SmsCodeGeneratorServiceImpl

@Data
@Component("smsCodeGeneratorService")
public class SmsCodeGeneratorServiceImpl implements ValidateCodeGeneratorService {

    @Autowired
    private ValidateCodeProperties validateCodeProperties;


    @Override
    public SmsCode generate(ServletWebRequest request) {
        //生成随机数
        String code = RandomStringUtils.randomNumeric(validateCodeProperties.getSms().getLength());
        return new SmsCode(code, validateCodeProperties.getSms().getExpireIn());
    }
}

4、验证码的发送逻辑类

获取的实现类写好了,我们在写发送具体的发送实现类,发送类的实现类是实现AbstractValidateCodeProcessorService抽象类的。

图片发送实现类

ImageCodeProcessorServiceImpl

@Component("imageCodeProcessorService")
public class ImageCodeProcessorServiceImpl extends AbstractValidateCodeProcessorService<ImageCode> {

    private static final String FORMAT_NAME = "JPEG";

    /**
     * 发送图形验证码,将其写到相应中
     *
     * @param request   ServletWebRequest实例对象
     * @param imageCode 验证码
     */
    @Override
    protected void send(ServletWebRequest request, ImageCode imageCode) throws Exception {
        ImageIO.write(imageCode.getImage(), FORMAT_NAME, request.getResponse().getOutputStream());
    }
}

短信发送具体实现类。这里只是后台输出就好了,实际中只要接入对于的SDK就可以了。

SmsCodeProcessorServiceImpl

@Component("smsCodeProcessorService")
public class SmsCodeProcessorServiceImpl extends AbstractValidateCodeProcessorService<SmsCode> {
    private static final String SMS_CODE_PARAM_NAME = "mobile";

    @Override
    protected void send(ServletWebRequest request, SmsCode smsCode) throws Exception {
        //这里有一个参数也是前端需要传来的 就是用户的手机号
        String mobile = ServletRequestUtils.getRequiredStringParameter(request.getRequest(), SMS_CODE_PARAM_NAME);
        // 这里仅仅写个打印,具体逻辑一般都是调用第三方接口发送短信
        System.out.println("向手机号为:" + mobile + "的用户发送验证码:" + smsCode.getCode());
    }

整个大致就是这样,我们再来测试一下。


三、测试

1、ValidateCodeController接口

获取验证码接口

@RestController
@Slf4j
public class ValidateCodeController {


    @Autowired
    private  Map<String, ValidateCodeProcessorService> validateCodeProcessorMap;


    @RequestMapping("/code/{type}")
    public void createCode(HttpServletRequest request, HttpServletResponse response, @PathVariable String type) throws Exception {
        if(!StringUtils.equalsAny(type, "image", "sms")){
            log.info("type类型错误 type={}",type);
            return;
        };

        //根据type获取具体的实现类
        ValidateCodeProcessorService validateCodeProcessorService = validateCodeProcessorMap.get(type.concat(ValidateCodeProcessorService.CODE_PROCESSOR));
        validateCodeProcessorService.processor(new ServletWebRequest(request, response));

    }

}

2、获得图形验证码

获取成功

3、获取短信验证码

获取短信验证码需要多传一个参数就是mobile 手机号码

因为这里发送短信没有接第三方SDK,而是直接在控制台输出

 

SpringSceurity(5)---短信验证码登陆功能

SpringSceurity(5)---短信验证码登陆功能

有关SpringSceurity系列之前有写文章

1、SpringSecurity(1)---认证+授权代码实现

2、SpringSecurity(2)---记住我功能实现

3、SpringSceurity(3)---图形验证码功能实现

4、SpringSceurity(4)---短信验证码功能实现

一、短信登录验证机制原理分析

了解短信验证码的登陆机制之前,我们首先是要了解用户账号密码登陆的机制是如何的,我们来简要分析一下Spring Security是如何验证基于用户名和密码登录方式的,

分析完毕之后,再一起思考如何将短信登录验证方式集成到Spring Security中。

1、账号密码登陆的流程

一般账号密码登陆都有附带 图形验证码 和 记住我功能 ,那么它的大致流程是这样的。

1、 用户在输入用户名,账号、图片验证码后点击登陆。那么对于springSceurity首先会进入短信验证码Filter,因为在配置的时候会把它配置在
UsernamePasswordAuthenticationFilter之前,把当前的验证码的信息跟存在session的图片验证码的验证码进行校验。

2、短信验证码通过后,进入 UsernamePasswordAuthenticationFilter 中,根据输入的用户名和密码信息,构造出一个暂时没有鉴权的
 UsernamePasswordAuthenticationToken,并将 UsernamePasswordAuthenticationToken 交给 AuthenticationManager 处理。

3、AuthenticationManager 本身并不做验证处理,他通过 for-each 遍历找到符合当前登录方式的一个 AuthenticationProvider,并交给它进行验证处理
,对于用户名密码登录方式,这个 Provider 就是 DaoAuthenticationProvider。

4、在这个 Provider 中进行一系列的验证处理,如果验证通过,就会重新构造一个添加了鉴权的 UsernamePasswordAuthenticationToken,并将这个
 token 传回到 UsernamePasswordAuthenticationFilter 中。

5、在该 Filter 的父类 AbstractAuthenticationProcessingFilter 中,会根据上一步验证的结果,跳转到 successHandler 或者是 failureHandler。

流程图

2、短信验证码登陆流程

因为短信登录的方式并没有集成到Spring Security中,所以往往还需要我们自己开发短信登录逻辑,将其集成到Spring Security中,那么这里我们就模仿账号

密码登陆来实现短信验证码登陆。

1、用户名密码登录有个 UsernamePasswordAuthenticationFilter,我们搞一个SmsAuthenticationFilter,代码粘过来改一改。
2、用户名密码登录需要UsernamePasswordAuthenticationToken,我们搞一个SmsAuthenticationToken,代码粘过来改一改。
3、用户名密码登录需要DaoAuthenticationProvider,我们模仿它也 implenments AuthenticationProvider,叫做 SmsAuthenticationProvider。

这个图是网上找到,自己不想画了

我们自己搞了上面三个类以后,想要实现的效果如上图所示。当我们使用短信验证码登录的时候:

1、先经过 SmsAuthenticationFilter,构造一个没有鉴权的 SmsAuthenticationToken,然后交给 AuthenticationManager处理。

2、AuthenticationManager 通过 for-each 挑选出一个合适的 provider 进行处理,当然我们希望这个 provider 要是 SmsAuthenticationProvider。

3、验证通过后,重新构造一个有鉴权的SmsAuthenticationToken,并返回给SmsAuthenticationFilter。
filter 根据上一步的验证结果,跳转到成功或者失败的处理逻辑。

二、代码实现

1、SmsAuthenticationToken

首先我们编写 SmsAuthenticationToken,这里直接参考 UsernamePasswordAuthenticationToken 源码,直接粘过来,改一改。

说明

principal 原本代表用户名,这里保留,只是代表了手机号码。
credentials 原本代码密码,短信登录用不到,直接删掉。
SmsCodeAuthenticationToken() 两个构造方法一个是构造没有鉴权的,一个是构造有鉴权的。
剩下的几个方法去除无用属性即可。

代码

public class SmsCodeAuthenticationToken extends AbstractAuthenticationToken {

    private static final long serialVersionUID = SpringSecurityCoreVersion.SERIAL_VERSION_UID;

    /**
     * 在 UsernamePasswordAuthenticationToken 中该字段代表登录的用户名,
     * 在这里就代表登录的手机号码
     */
    private final Object principal;

    /**
     * 构建一个没有鉴权的 SmsCodeAuthenticationToken
     */
    public SmsCodeAuthenticationToken(Object principal) {
        super(null);
        this.principal = principal;
        setAuthenticated(false);
    }

    /**
     * 构建拥有鉴权的 SmsCodeAuthenticationToken
     */
    public SmsCodeAuthenticationToken(Object principal, Collection<? extends GrantedAuthority> authorities) {
        super(authorities);
        this.principal = principal;
        // must use super, as we override
        super.setAuthenticated(true);
    }

    @Override
    public Object getCredentials() {
        return null;
    }

    @Override
    public Object getPrincipal() {
        return this.principal;
    }

    @Override
    public void setAuthenticated(boolean isAuthenticated) throws IllegalArgumentException {
        if (isAuthenticated) {
            throw new IllegalArgumentException(
                    "Cannot set this token to trusted - use constructor which takes a GrantedAuthority list instead");
        }

        super.setAuthenticated(false);
    }

    @Override
    public void eraseCredentials() {
        super.eraseCredentials();
    }
}

2、SmsAuthenticationFilter

然后编写 SmsAuthenticationFilter,参考 UsernamePasswordAuthenticationFilter 的源码,直接粘过来,改一改。

说明

原本的静态字段有 username 和 password,都干掉,换成我们的手机号字段。
SmsCodeAuthenticationFilter() 中指定了这个 filter 的拦截 Url,我指定为 post 方式的 /sms/login
剩下来的方法把无效的删删改改就好了。

代码

public class SmsCodeAuthenticationFilter extends AbstractAuthenticationProcessingFilter {
    /**
     * form表单中手机号码的字段name
     */
    public static final String SPRING_SECURITY_FORM_MOBILE_KEY = "mobile";

    private String mobileParameter = "mobile";
    /**
     * 是否仅 POST 方式
     */
    private boolean postOnly = true;

    public SmsCodeAuthenticationFilter() {
        //短信验证码的地址为/sms/login 请求也是post
        super(new AntPathRequestMatcher("/sms/login", "POST"));
    }

    @Override
    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 mobile = obtainMobile(request);
        if (mobile == null) {
            mobile = "";
        }

        mobile = mobile.trim();

        SmsCodeAuthenticationToken authRequest = new SmsCodeAuthenticationToken(mobile);

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

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

    protected String obtainMobile(HttpServletRequest request) {
        return request.getParameter(mobileParameter);
    }

    protected void setDetails(HttpServletRequest request, SmsCodeAuthenticationToken authRequest) {
        authRequest.setDetails(authenticationDetailsSource.buildDetails(request));
    }

    public String getMobileParameter() {
        return mobileParameter;
    }

    public void setMobileParameter(String mobileParameter) {
        Assert.hasText(mobileParameter, "Mobile parameter must not be empty or null");
        this.mobileParameter = mobileParameter;
    }

    public void setPostOnly(boolean postOnly) {
        this.postOnly = postOnly;
    }
}

3、SmsAuthenticationProvider

这个方法比较重要,这个方法首先能够在使用短信验证码登陆时候被 AuthenticationManager 挑中,其次要在这个类中处理验证逻辑。

说明

实现 AuthenticationProvider 接口,实现 authenticate() 和 supports() 方法。

代码

public class SmsCodeAuthenticationProvider implements AuthenticationProvider {

    private UserDetailsService userDetailsService;

    /**
     * 处理session工具类
     */
    private SessionStrategy sessionStrategy = new HttpSessionSessionStrategy();

    String SESSION_KEY_PREFIX = "SESSION_KEY_FOR_CODE_SMS";

    @Override
    public Authentication authenticate(Authentication authentication) throws AuthenticationException {
        SmsCodeAuthenticationToken authenticationToken = (SmsCodeAuthenticationToken) authentication;

        String mobile = (String) authenticationToken.getPrincipal();

        checkSmsCode(mobile);

        UserDetails userDetails = userDetailsService.loadUserByUsername(mobile);
        // 此时鉴权成功后,应当重新 new 一个拥有鉴权的 authenticationResult 返回
        SmsCodeAuthenticationToken authenticationResult = new SmsCodeAuthenticationToken(userDetails, userDetails.getAuthorities());
        authenticationResult.setDetails(authenticationToken.getDetails());

        return authenticationResult;
    }

    private void checkSmsCode(String mobile) {
        HttpServletRequest request = ((ServletRequestAttributes) RequestContextHolder.getRequestAttributes()).getRequest();
        // 从session中获取图片验证码
        SmsCode smsCodeInSession = (SmsCode) sessionStrategy.getAttribute(new ServletWebRequest(request), SESSION_KEY_PREFIX);
        String inputCode = request.getParameter("smsCode");
        if(smsCodeInSession == null) {
            throw new BadCredentialsException("未检测到申请验证码");
        }

        String mobileSsion = smsCodeInSession.getMobile();
        if(!Objects.equals(mobile,mobileSsion)) {
            throw new BadCredentialsException("手机号码不正确");
        }

        String codeSsion = smsCodeInSession.getCode();
        if(!Objects.equals(codeSsion,inputCode)) {
            throw new BadCredentialsException("验证码错误");
        }
    }

    @Override
    public boolean supports(Class<?> authentication) {
        // 判断 authentication 是不是 SmsCodeAuthenticationToken 的子类或子接口
        return SmsCodeAuthenticationToken.class.isAssignableFrom(authentication);
    }

    public UserDetailsService getUserDetailsService() {
        return userDetailsService;
    }

    public void setUserDetailsService(UserDetailsService userDetailsService) {
        this.userDetailsService = userDetailsService;
    }
}

4、SmsCodeAuthenticationSecurityConfig

既然自定义了拦截器,可以需要在配置里做改动。

代码

@Component
public class SmsCodeAuthenticationSecurityConfig extends SecurityConfigurerAdapter<DefaultSecurityFilterChain, HttpSecurity> {
    @Autowired
    private SmsUserService smsUserService;
    @Autowired
    private AuthenctiationSuccessHandler authenctiationSuccessHandler;
    @Autowired
    private AuthenctiationFailHandler authenctiationFailHandler;

    @Override
    public void configure(HttpSecurity http) {
        SmsCodeAuthenticationFilter smsCodeAuthenticationFilter = new SmsCodeAuthenticationFilter();
        smsCodeAuthenticationFilter.setAuthenticationManager(http.getSharedObject(AuthenticationManager.class));
        smsCodeAuthenticationFilter.setAuthenticationSuccessHandler(authenctiationSuccessHandler);
        smsCodeAuthenticationFilter.setAuthenticationFailureHandler(authenctiationFailHandler);

        SmsCodeAuthenticationProvider smsCodeAuthenticationProvider = new SmsCodeAuthenticationProvider();
        //需要将通过用户名查询用户信息的接口换成通过手机号码实现
        smsCodeAuthenticationProvider.setUserDetailsService(smsUserService);

        http.authenticationProvider(smsCodeAuthenticationProvider)
                .addFilterAfter(smsCodeAuthenticationFilter, UsernamePasswordAuthenticationFilter.class);
    }
}

5、SmsUserService

因为用户名,密码登陆最终是通过用户名查询用户信息,而手机验证码登陆是通过手机登陆,所以这里需要自己再实现一个SmsUserService

@Service
@Slf4j
public class SmsUserService implements UserDetailsService {

    @Autowired
    private UserMapper userMapper;

    @Autowired
    private RolesUserMapper rolesUserMapper;

    @Autowired
    private RolesMapper rolesMapper;

    /**
     * 手机号查询用户
     */
    @Override
    public UserDetails loadUserByUsername(String mobile) throws UsernameNotFoundException {
        log.info("手机号查询用户,手机号码 = {}",mobile);
        //TODO 这里我没有写通过手机号去查用户信息的sql,因为一开始我建user表的时候,没有建mobile字段,现在我也不想临时加上去
        //TODO 所以这里暂且写死用用户名去查询用户信息(理解就好)
        User user = userMapper.findOneByUsername("小小");
        if (user == null) {
            throw new UsernameNotFoundException("未查询到用户信息");
        }
        //获取用户关联角色信息 如果为空说明用户并未关联角色
        List<RolesUser> userList = rolesUserMapper.findAllByUid(user.getId());
        if (CollectionUtils.isEmpty(userList)) {
            return user;
        }
        //获取角色ID集合
        List<Integer> ridList = userList.stream().map(RolesUser::getRid).collect(Collectors.toList());
        List<Roles> rolesList = rolesMapper.findByIdIn(ridList);
        //插入用户角色信息
        user.setRoles(rolesList);
        return user;
    }
}

6、总结

到这里思路就很清晰了,我这里在总结下。

1、首先从获取验证的时候,就已经把当前验证码信息存到session,这个信息包含验证码和手机号码。

2、用户输入验证登陆,这里是直接写在SmsAuthenticationFilter中先校验验证码、手机号是否正确,再去查询用户信息。我们也可以拆开成用户名密码登陆那样一个
过滤器专门验证验证码和手机号是否正确,正确在走验证码登陆过滤器。

3、在SmsAuthenticationFilter流程中也有关键的一步,就是用户名密码登陆是自定义UserService实现UserDetailsService后,通过用户名查询用户名信息而这里是
通过手机号查询用户信息,所以还需要自定义SmsUserService实现UserDetailsService后。


三、测试

1、获取验证码

获取验证码的手机号是 15612345678 。因为这里没有接第三方的短信SDK,只是在后台输出。

向手机号为:15612345678的用户发送验证码:254792

2、登陆

1)验证码输入不正确

发现登陆失败,同样如果手机号码输入不对也是登陆失败

2)登陆成功

当手机号码 和 短信验证码都正确的情况下 ,登陆就成功了。

SpringSceurity(6)---JWT详解

SpringSceurity(6)---JWT详解

在JWT之前我们在做用户认证的时候,基本上会考虑session 和 token,所以在讲jwt之前,我们先来回顾下这个两个

一、传统的session认证

1、原理流程

session 是基于 cookie 实现的,session 存储在服务器端,sessionId 会被存储到客户端的cookie 中,具体流程如下

session 认证流程

1、用户第一次请求服务器的时候,服务器根据用户提交的相关信息,创建对应的 Session

2、请求返回时将此 Session 的唯一标识信息 SessionID 返回给浏览器。

3、浏览器接收到服务器返回的 SessionID 信息后,会将此信息存入到 Cookie 中,同时 Cookie 记录此 SessionID 属于哪个域名。

4、当用户第二次访问服务器的时候,请求会自动判断此域名下是否存在 Cookie 信息,如果存在自动将 Cookie 信息也发送给服务端,服务端会 从 Cookie 中获取 SessionID,

再根据 SessionID 查找对应的 Session 信息,如果没有找到说明用户没有登录或者登录失效,如果找到 Session 证明用户已经登录可执行后面操作。

总结 根据以上流程可知,SessionID 是连接 Cookie 和 Session 的一道桥梁,大部分系统也是根据此原理来验证用户登录状态。

2、session 时需要考虑的问题

1、将 session 存储在服务器里面,当用户同时在线量比较多时,这些 session 会占据较多的内存,需要在服务端定期的去清理过期的 session。

2、当网站采用集群部署的时候,会遇到多台 web 服务器之间如何做 session 共享的问题。因为 session 是由单个服务器创建的,但是处理用户请求

的服务器不一定是那个创建 session 的服务器,那么该服务器就无法拿到之前已经放入到 session 中的登录凭证之类的信息了。

3、当多个应用要共享 session 时,除了以上问题,还会遇到跨域问题,因为不同的应用可能部署的主机不一样,需要在各个应用做好 cookie 跨域的处理。

4、sessionId 是存储在 cookie 中的,假如浏览器禁止 cookie 或不支持 cookie 怎么办?

5、移动端对 cookie 的支持不是很好,而 session 需要基于 cookie 实现,所以移动端常用的是 token。

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

二、Token(令牌)

1、原理流程

基于token的鉴权机制类似于http协议也是无状态的,它不需要在服务端去保留用户的认证信息或者会话信息

这个不是标准时序图,能看懂大致意思就行。

token认证流程

1、客户端使用用户名跟密码请求登录。

2、服务端收到请求,去认证服务器验证用户名与密码。

3、验证成功后,服务端会签发一个 token 并把这个 token 发送给客户端。

4、客户端收到 token 以后,会把它存储起来,比如放在 cookie 里或者 localStorage 里。

5、客户端每次向服务端请求资源的时候需要带着服务端签发的 token。

6、服务端收到请求,然后去验证客户端请求里面带着的 token ,如果验证成功,就向客户端返回请求的数据。

2、token特点

1、如果你认为用数据库来存储 token 会导致查询时间太长,可以选择放在内存当中。比如 redis 很适合你对 token 查询的需求。

2、token 完全由应用管理,所以它可以避开同源策略。

3、token 可以避免 CSRF 攻击(因为不需要 cookie 了)

4、移动端对 cookie 的支持不是很好,而 session 需要基于 cookie 实现,所以移动端常用的是 token

3、Token 和 Session 的区别

Session 是一种记录服务器和客户端会话状态的机制,使服务端有状态化,可以记录会话信息。而 Token 是令牌,访问资源接口(API)时所需要的资源凭证。Token

使服务端无状态化,不会存储会话信息。

有关Token 和 Session可以看下这篇博客 彻底理解cookie、session、token


三、JWT

我们在使用token的时候会发现,前端给我们传了token之后,我们还需要拿者这个token去数据库查询用户信息,并返回。这样数据库的操作肯定会影响一定的性能,

那jwt就是来解决这个的。

1、原理流程

JWT 认证流程

1、用户输入用户名/密码登录,服务端认证成功后,会返回给客户端一个 JWT。

2、客户端将 jwt 保存到本地(通常使用 localstorage,也可以使用 cookie)。

3、当用户希望访问一个受保护的路由或者资源的时候,需要请求头的 Authorization 字段中使用Bearer 模式添加 JWT,其内容看起来是下面这样 Authorization: Bearer jwt

4、服务端的保护路由将会检查请求头 Authorization 中的 JWT 信息,如果合法,则允许用户的行为。

因为 JWT 是自包含的(内部包含了一些会话信息),因此减少了需要查询数据库的需要,因为 JWT 并不使用 Cookie 的,所以你可以使用任何域名提供你的

API 服务而不需要担心跨域资源共享问题(CORS),因为用户的状态不再存储在服务端的内存中,所以这是一种无状态的认证机制。

2、Token 和 JWT 的区别

相同

1、都是访问资源的令牌

2、都可以记录用户的信息

3、都是使服务端无状态化

4、都是只有验证成功后,客户端才能访问服务端上受保护的资源

区别

Token 服务端验证客户端发送过来的 Token 时,还需要查询数据库获取用户信息,然后验证 Token 是否有效。

JWT 将 Token 和 Payload 加密后存储于客户端,服务端只需要使用密钥解密进行校验(校验也是 JWT 自己实现的)即可,不需要查询或者减少查询数据库,

因为 JWT 自包含了用户信息和加密的数据。

3、JWT特点

从优点来讲,它最大的优点就是,当服务端拿到JWT之后,我们不需要向token样还需去查询数据库校验信息,因为JWT中就包含用户信息,所以减少一次数据的查询,

但这样做也会带来很明显的问题

1、无法满足修改密码场景

因为上面说过,服务端拿到jwt是不会在去查询数据库的,所以就算你改了密码,服务端还是未知的。那么假设号被到了,修改密码(是用户密码,不是 jwt 的 secret)之后,

盗号者在原 jwt 有效期之内依旧可以继续访问系统,所以仅仅清空 cookie 自然是不够的,这时,需要强制性的修改 secret。

2、无法满足注销场景

传统的 session+cookie 方案用户点击注销,服务端清空 session 即可,因为状态保存在服务端。但 jwt 的方案就比较难办了,因为 jwt 是无状态的,服务端通过计算来校验

有效性。没有存储起来,所以即使客户端删除了 jwt,但是该 jwt 还是在有效期内,只不过处于一个游离状态。

3、无法满足token续签场景

我们知道微信只要你每天使用是不需要重新登录的,因为有token续签,因为传统的 cookie 续签方案一般都是框架自带的,session 有效期 30 分钟,30 分钟内如果有访问

,session 有效期被刷新至 30 分钟。但是 jwt 本身的 payload 之中也有一个 exp 过期时间参数,来代表一个 jwt 的时效性,而 jwt 想延期这个 exp 就有点身不由己了,因为

payload 是参与签名的,一旦过期时间被修改,整个 jwt 串就变了,jwt 的特性天然不支持续签!


四、JWT的结构

JWT由三部分组成,分别是 头信息有效载荷,签名 中间以 点(.) 分隔,具体如下

  xxxxx.yyyyy.zzzzz

1、header(头信息)

由两部分组成,令牌类型(即:JWT)、散列算法(HMAC、RSASSA、RSASSA-PSS等),例如:

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

然后,这个JSON被编码为Base64Url,形成JWT的第一部分。

2、Payload(有效载荷)

JWT的第二部分是payload,其中包含claims。claims是关于实体(常用的是用户信息)和其他数据的声明,claims有三种类型: registered, public, and private claims。

Registered claims:这些是一组预定义的claims,非强制性的,但是推荐使用, iss(发行人), exp(到期时间), sub(主题), aud(观众)等;

Public claims: 自定义claims,注意不要和JWT注册表中属性冲突,这里可以查看JWT注册表。

Private claims: 这些是自定义的claims,用于在同意使用这些claims的各方之间共享信息,它们既不是Registered claims,也不是Public claims。

示例

 {
   "sub": "1234567890",
   "name": "John Doe",
   "admin": true
 }

然后,再经过Base64Url编码,形成JWT的第二部分;

注意:对于签名令牌,此信息虽然可以防止篡改,但任何人都可以读取。除非加密,否则不要将敏感信息放入到Payload或Header元素中。

3、Signature(签名)

jwt的第三部分是一个签证信息,这个签证信息由三部分组成:header (base64后的) ,payload (base64后的)secret

这个部分需要base64加密后的header和base64加密后的payload使用.连接组成的字符串,然后通过header中声明的加密方式进行加盐secret组合加密,然后就构成了jwt的第三部分。

var encodedString = base64UrlEncode(header) + '.' + base64UrlEncode(payload);

var signature = HMACSHA256(encodedString, 'secret'); // TJVA95OrM7E2cBab30RMHrHDcEfxjoYZgeFONFh7HgQ

将这三部分用.连接成一个完整的字符串,构成了最终的jwt:

 eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9
.eyJzdWIiOiIxMjM0NTY3ODkwIiwibmFtZSI6IkpvaG4gRG9lIiwiYWRtaW4iOnRydWV9
.TJVA95OrM7E2cBab30RMHrHDcEfxjoYZgeFONFh7HgQ

注意 secret是保存在服务器端的,jwt的签发生成也是在服务器端的,secret就是用来进行jwt的签发和jwt的验证,所以,它就是你服务端的私钥,

在任何场景都不应该流露出去。一旦客户端得知这个secret, 那就意味着客户端是可以自我签发jwt了。

4、如何应用

一般是在请求头里加入Authorization,并加上Bearer标注:

fetch('api/user/1', {
  headers: {
    'Authorization': 'Bearer ' + token
  }
})

SpringSceurity(7)--- Security+JWT搭建简单的授权服务器和资源服务器

Security+JWT搭建简单的授权服务器和资源服务器

之前有两篇博客分别将来介绍了 OAuth 2.0 和 JWT

1、OAuth 2.0详解

2、SpringSceurity(6)---JWT详解

这边通过Security+JWT搭建简单的授权服务器和资源服务器示例。

整体流程大概是这样的

JWT 认证流程

1、用户先访问授权服务器。传入用户输入用户名/密码登录,授权服务器认证成功后,会返回给客户端一个 JWT
2、用户在访问资源服务器。当用户希望访问一个受保护的路由或者资源的时候,需要请求头的 Authorization 字段中使用Bearer 模式添加 JWT,其内容看起来是下面这样
   Authorization: Bearer复制代码
3、资源服务器保护路由将会检查请求头 Authorization 中的 JWT 信息,如果合法,则允许用户的行为

下面开始搭建

一、搭建授权服务器

授权服务器配置类

/**
 * @Description: 授权服务器配置
 *   使用 @EnableAuthorizationServer 来配置授权服务机制,并继承 AuthorizationServerConfigurerAdapter 该类重写 configure 方法定义授权服务器策略
 */
@Configuration
@EnableAuthorizationServer
public class OAuth2AuthorizationServer extends AuthorizationServerConfigurerAdapter {

    @Autowired
    private AuthenticationManager authenticationManager;

    /**
     * 使用同一个密钥来编码 JWT 中的  OAuth2 令牌,在资源服务器解析JWT的时候需要有一样的密钥
     */
    @Bean
    public JwtAccessTokenConverter accessTokenConverter() {
        JwtAccessTokenConverter converter = new JwtAccessTokenConverter();
        converter.setSigningKey("dada");
        return converter;
    }

    @Bean
    public JwtTokenStore jwtTokenStore() {
        return new JwtTokenStore(accessTokenConverter());
    }

    /**
     * 告诉Spring Security Token的生成方式
     */
    @Override
    public void configure(AuthorizationServerEndpointsConfigurer endpoints) throws Exception {
        endpoints
            .authenticationManager(authenticationManager)
            .tokenStore(jwtTokenStore())
            .accessTokenConverter(accessTokenConverter());
    }

    @Override
    public void configure(ClientDetailsServiceConfigurer clients) throws Exception {
        //添加客户端信息 使用in-memory存储客户端信息
        clients.inMemory()
                //客户端标识 ID
            .withClient("clientId")
                //客户密钥
            .secret("666666")
                //客户端访问范围,默认为空则拥有全部范围
            .scopes("read_userinfo")
                //客户端使用的授权类型,默认为空
            .authorizedGrantTypes(
                "password",
                "authorization_code",
                "refresh_token");
    }
}

application.properties配置类

security.user.name=xuxiaoxiao
security.user.password=123456

这里设置 用户名为: xuxiaoxiao 密码为: 123456 端口号默认为: 8080

说明 有关其它代码这里就不再贴出,最后会给出整个demo的github地址。


二、搭建资源服务器

资源服务器配置类

/**
 * @Description: 资源服务器通过 @EnableResourceServer 注解来开启一个 OAuth2AuthenticationProcessingFilter 类型的过滤器
 * 通过继承 ResourceServerConfigurerAdapter 类来配置资源服务器
 */
@Configuration
@EnableResourceServer
public class OAuth2ResourceServer extends ResourceServerConfigurerAdapter {
    @Override
    public void configure(HttpSecurity http) throws Exception {
        http
            .authorizeRequests()
            .anyRequest().authenticated().and()
            .requestMatchers().antMatchers("/api/**");
    }

    @Bean
    public TokenStore tokenStore() {
        return new JwtTokenStore(jwtAccessTokenConverter());
    }

    /**
     * 与授权服务器使用共同的密钥进行解析
     */
    @Bean
    public JwtAccessTokenConverter jwtAccessTokenConverter() {
        JwtAccessTokenConverter converter = new JwtAccessTokenConverter();
        converter.setSigningKey("dada");
        return converter;
    }
}

UserController

@Controller
public class UserController {

    @RequestMapping("/api/userinfo")
    public ResponseEntity<String> getUerInfo() {
        //这里会做解析jwt操作,获取jwt中的用户名
        String username = (String) SecurityContextHolder.getContext().getAuthentication().getPrincipal();
        return ResponseEntity.ok(username);
    }

}

application.properties

server.port=8081

这里只是配置端口号为 8081


三、测试

1、整体测试流程

1.)启动jwt-authserver(授权服务器),端口8080

2.) 启动jwt-resourceserver(资源服务器),端口8081

3.) 请求授权服务器,获取jwt令牌

curl -X POST --user clientId:666666 http://localhost:8080/oauth/token -H "accept: application/json" -H "content-type: application/x-www-formurlencoded" 
-d "grant_type=password&username=xuxiaoxiao&password=123456&scope=read_userinfo"

4.) 带上jwt令牌请求资源服务器

curl -X GET http://localhost:8081/api/userinfo -H "authorization: Bearer 上面返回的jwt令牌

2、postman测试

先请求授权服务器

从请求中可以看出,授权服务器已经成功返回 access_token

再请求资源服务器

因为上面授权服务器已经返回access_token,那么我们在请求资源服务器的时候,就可以在请求头中加入这个token。

可以看出资源服务器已经成功解析这个access_token,获取到当前用户名。

总结:这个demo实现了基于Security+JWT搭建简单的授权服务器和资源服务器,也仅仅是搭建了简单的demo。里面的数据都是写死的并没有通过配置走,对于配置类也没有做详细的说明。

OAuth 2.0详解

OAuth 2.0详解

概念:OAuth(开放授权)是一个开放标准,允许用户让第三方应用访问该用户在某一网站上存储的私密的资源(如基本消息,照片,联系人列表),

而无需将 用户名 和 密码 提供给第三方应用。

一、应用场景

为了理解OAuth的适用场合,这里举一个使用第三方账户进行登录的例子。

现在一般登陆都会采用 第三方授权 登陆,比较常见就是微信、qq、微博授权登陆。这里以微信授权登陆为例:

现在我在未注册的情况下去访问A网站,A网站 为了提高用户体验,可以省去你在这次网站申请注册的步骤,让你通过微信授权登陆去拿去你在微信上的基本信息。

问题就在这里,如果拿到微信用户基本信息给到A网站,直接给A网站我登陆微信的账号密码,那么问题可想而知。

1、这个也太不安全了,我只想给A网站我的在微信上的基本信息,而不是所有信息,通过用户密码可以获取我的所有信息。

2、用户只有修改密码,才能收回赋予"A网站"的权力。但是这样做,会使得其他所有获得用户授权的第三方应用程序全部失效。

3、只要有一个第三方应用程序被破解,就会导致用户密码泄漏,以及所有被密码保护的数据泄漏。

OAuth就是为了解决上面这些问题而诞生的。

从上面可以看出主要有三个身份

用户

使用第三方账户登录一个新的网站,对于用户来说就不需要走复杂的注册流程。

第三方平台(微信)

上面来讲 微信 就是第三方平台,那么对于第三方如何做才能保证用户的安全呢?

就在A网站在通过微信授权登陆之前,需要提供资质到微信,微信审核,审核通过后给要求接入的服务商一个唯一凭证,标明服务商身份。

服务商(A网站)

我们要做的就是将这两者进行连接起来,先到第三方平台资质审核,审核通过后,用户去第三方平台授权登录后,就可以获取用户基本信息,完成登陆。


二、OAuth的思路

这里还是以 服务商(A网站),和 第三方平台(微信)授权登录来缕这个思路。

OAuth在 服务商(A网站) 与 第三方平台(微信) 之间,设置了一个授权层(authorization layer)。服务商 不能直接登录 第三方平台,只能登录授权层,

以此将用户与服务商(A网站)区分开来。服务商(A网站) 登录授权层所用的令牌(token),与用户的密码不同。用户可以在登录的时候,指定授权层令牌的权限范围和有效期。

服务商(A网站) 登录授权层以后,第三方平台 根据令牌的权限范围和有效期,向 服务商(A网站) 开放用户储存的资料。

这里缕下大致流程

1、接入前准备(资质审核)

如果一个服务商需要使用第三方平台的服务,那么首先是需要向第三方平台提供资料,第三方平台审核通过后,会给服务商一个唯一标识的ID,这样通过第三方平台授权的时候,

第三方平台就知道是哪个商户了。

一般来说你会得到如下的两个参数:

appid 代表你的应用唯一ID
appsecret 对应的密钥

这个部分每家平台都不一样,具体如何获取你的APPID请参考对应平台的指南.

注意 第三方平台给你的不一定是APPID,我的意思不是连名字都完全一样,有的平台给的参数多有的给的少,总之都是用于验明身份的.

2、用户要使用第三方登陆

这里我们以登录为例.

在这个流程中服务器(A网站)接受到了用户想要第三方登录的请求,我们使用之前获取的APPID(不同平台叫法和参数可能不同),然后拼接为成第三方平台指定的url

然后直接重定向到这个url.

例如在这个例子中我们的地址可能长这个样子:

www.xxx.com/oauth2.0/authorize?appid=123456&redirect=www.sss.com/login

参数:

appid 我们的应用对于第三方平台的唯一id
redirect 用户同意授权后被重定向的地址,一般来说都是本应用的首页或者登录页面,在本例中就是www.sss.com/login这个地址.
其他参数 根据第三方平会有不同的额外参数.

然后将用户重定向到这个url中,此时用户会跳转到www.xxx.com(因为如果用户授权成功,你总要回调服务商接口,来告诉它,已经授权成功).

3、用户授权成功

用户授权成功后,微信就会请求上面redirect参数中的接口地址,带上授权成功的参数code

在这个例子中这个url看起来是这个样子的

www.sss.com/login?code=xxxxx

4、获取用户token(令牌)

此时我们的www.sss.com/login接受到了一个含有code的请求,我们知道这个是一个第三方登录授权后的请求.

我们再次拼接一个url(不同平台地址规则不同),但是一般来说这个请求会有如下的参数:

code 用户授权后重定向带回来的code
appid 应用唯一id
appsecret 应用对应的密钥

在这个例子中我们请求服务器的url可能是这个样子的:

www.xxx.com/oauth2/access_token?appid=xxxx&secert=xxxx&code=xxxx

如果一切顺利在这个阶段我们就可以获取第三方平台响应的一个accesstoken,这个accesstoken代表着用户对于这个应用的授权.

除此以外你还会获取到用户的基本信息例如用户的唯一id之类的,后续的请求用户的信息需要使用accesstoken进行请求。

5、获取用户基本信息

利用accesstoken我们向服务器获取了用户的名字,显示在了我们的应用中, 后续的资源获取就是这个模式(不同平台资源获取地址以及方式有可能稍有不同).

6、补充

1、微信认证成功后,我会会把accesstoken存放在cookie中,这样不用每次都需要用户去授权认证,而是我们后台去请问微信,这个时候用户是不会感知的。

2、accesstoken不是一直有效的,它会有过期时间的,就好比微信扫码登陆中accesstoken有效时间是2小时。

3、那么accesstoken时效,是不是就要用户重新授权登陆了,当然也不是,如果没2小时都要重新授权登陆那体验也太差了。这里会有个叫refresh_token

它是在你第一次获取accesstoken一起给你的,也就是说如果你的accesstoken时效了,你还可以通过refresh_token去获取用户信息。这么说refresh_token的

时效时间肯定要比accesstoken,微信扫码登陆refresh_token有效时间是30天

4、也就是当refresh_token也时效的时候,才会需要用户重新授权登陆。

 

posted @ 2022-02-22 21:26  hanease  阅读(669)  评论(0编辑  收藏  举报