最近项目中用到spring-security,学习并记录一下用法
本次学习参考大神(woyouzhuguli)的文章 ,需要学习的建议到原文学习,以下仅为个人的实战记录。
1.初次使用Spring Security 1-1.官网介绍
Spring Security is a powerful and highly customizable authentication and access-control framework. It is the de-facto standard for securing Spring-based applications.
Spring Security is a framework that focuses on providing both authentication and authorization to Java applications. Like all Spring projects, the real power of Spring Security is found in how easily it can be extended to meet custom requirements
Spring Security提供认证和授权两大模块,与 Apache Shiro相比有更强大的功能。
1-2.创建项目并试用 创建springboot项目,引入security(把web也选上方便测试):
1 2 3 4 5 6 7 8 <dependency> <groupId>org.springframework.boot</groupId> <artifactId>spring-boot-starter-security</artifactId> </dependency> <dependency> <groupId>org.springframework.boot</groupId> <artifactId>spring-boot-starter-web</artifactId> </dependency>
创建一个TestController:
1 2 3 4 5 6 7 8 9 @RestController @RequestMapping ("/test" )public class TestController { @RequestMapping ("/trySecurity" ) public String trySecurity () { return "trySecurity" ; } }
启动并访问localhost:8080/test/trySecurity:
看到地址直接重定向到login,此页面就是由框架自带的。
账号默认为user,密码每次都不一样,在控制台可以看到密码输出:
输入后登入,返回正常内容:
2.自定义配置 2-1.登录认证页面 框架自带除了基于表单形式提供登录验证,也有弹出提示框的形式。
新建MySecurityConfig配置类:
1 2 3 4 5 6 7 8 9 10 11 12 @Configuration public class MySecurityConfig extends WebSecurityConfigurerAdapter { @Override protected void configure (HttpSecurity http) throws Exception { http.httpBasic() .and() .authorizeRequests() .anyRequest() .authenticated(); } }
修改为以HTTP Basic方式提供验证,重新启动:
也可以不使用框架自带的登录页面,修改为自定义页面。
在src/main/resources/resources/static目录下定义一个login.html:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 <!DOCTYPE html> <html> <head> <meta charset="UTF-8" > <title>登录</title> <link rel="stylesheet" href="css/login.css" type="text/css" > </head> <body> <form class ="login-page" action="/login" method="post" > <div class ="form" > <h3>账户登录</h3> <input type="text" placeholder="用户名" name="username" required="required" /> <input type="password" placeholder="密码" name="password" required="required" /> <button type="submit">登录</button> </div> </form> </body> </html>
然后修改MySecurityConfig中的config方法:
1 2 3 4 5 6 7 8 9 10 11 12 13 @Override protected void configure (HttpSecurity http) throws Exception { http.formLogin() .loginPage("/login.html" ) .loginProcessingUrl("/login" ) .and() .authorizeRequests() .antMatchers("/login.html" ).permitAll() .anyRequest() .authenticated() .and().csrf().disable(); }
重新启动并访问localhost:8080/test/trySecurity:
2-2.自定义用户认证 自定义认证可以在登录后进行一些处理逻辑。
第一步,创建用户对象 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 public class MyUser implements Serializable { private String userName; private String password; private String identity; public String getUserName () { return userName; } public void setUserName (String userName) { this .userName = userName; } public String getPassword () { return password; } public void setPassword (String password) { this .password = password; } public String getIdentity () { return identity; } public void setIdentity (String identity) { this .identity = identity; } }
第二步,实现security的UserDetailsService接口,重写loadUserByUsername方法: 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 @Configuration public class UserDetailServiceImpl implements UserDetailsService { @Autowired private PasswordEncoder passwordEncoder; @Override public UserDetails loadUserByUsername (String username) throws UsernameNotFoundException { MyUser user = new MyUser(); user.setUserName(username); user.setPassword("123456" ); user.setPassword(this .passwordEncoder.encode(user.getPassword())); return new User(username, user.getPassword(), true , true , true , true , AuthorityUtils.commaSeparatedStringToAuthorityList("admin" )); } }
这里有个密码的加密方法,在配置类中配置:
1 2 3 4 5 6 7 8 @Configuration public class MySecurityConfig extends WebSecurityConfigurerAdapter { ...... @Bean public PasswordEncoder passwordEncoder () { return new BCryptPasswordEncoder(); } }
第三步,实现AuthenticationSuccessHandler接口,重写onAuthenticationSuccess方法: 1 2 3 4 5 6 7 8 9 10 11 12 @Component public class MyAuthenticationSucessHandler implements AuthenticationSuccessHandler { private RedirectStrategy redirectStrategy = new DefaultRedirectStrategy(); @Override public void onAuthenticationSuccess (HttpServletRequest httpServletRequest, HttpServletResponse httpServletResponse, Authentication authentication) throws IOException, ServletException { redirectStrategy.sendRedirect(httpServletRequest, httpServletResponse, "/test/trySecurity" ); } }
第四步,在MySecurityConfig配置类中加上该Handler: 1 2 3 4 5 6 7 8 9 10 11 12 13 14 @Override protected void configure (HttpSecurity http) throws Exception { http.formLogin() .loginPage("/login.html" ) .loginProcessingUrl("/login" ) .successHandler(authenticationSucessHandler) .and() .authorizeRequests() .antMatchers("/login.html" ).permitAll() .anyRequest() .authenticated() .and().csrf().disable(); }
重新启动项目,访问/test/trySecurity,路径直接跳转到/login.html,输入用户名密码后重定向到/test/trySecurity并正确输出“trySecurity”。
自定义登录失败后的逻辑同理:
1 2 3 4 5 6 7 @Component public class MyAuthenticationFailureHandler implements AuthenticationFailureHandler { @Override public void onAuthenticationFailure (HttpServletRequest httpServletRequest, HttpServletResponse httpServletResponse, AuthenticationException e) throws IOException, ServletException { } }
configure方法加上:
1 .failureHandler(myAuthenticationFailureHandler)
第五步,退出登录 默认url为/logout,退出后会自动使session失效,删除下面即将使用的“remember-me”记录,重定向到登录页。
当然也可以自定义处理逻辑。
实现LogoutSuccessHandler,重写onLogoutSuccess方法:
1 2 3 4 5 6 7 @Component public class MyLogOutSuccessHandler implements LogoutSuccessHandler { @Override public void onLogoutSuccess (HttpServletRequest httpServletRequest, HttpServletResponse httpServletResponse, Authentication authentication) throws IOException, ServletException { } }
然后老套路configure方法加上:
1 2 3 4 5 6 .and() .logout() .logoutUrl("/logout" ) .logoutSuccessHandler(logOutSuccessHandler) .deleteCookies("JSESSIONID" ) .deleteCookies("remember-me" )
当然也可以不使用handle,直接重定向到任意路径:
1 2 3 4 5 6 .and() .logout() .logoutUrl("/logout" ) .logoutSuccessUrl("/logout/success" ) .deleteCookies("JSESSIONID" ) .deleteCookies("remember-me" )
3.更多其他功能 3-1.“记住我”自动登录 security也提供了“记住我”功能,在登录时勾选会在一定时间内保存认证信息。实现方法就是框架会生成一个token保存到数据库,同时会生成一个cookie返回给客户端,只要cookie没有过期,就可以完成自动认证。
添加数据库依赖:
1 2 3 4 5 6 7 8 <dependency> <groupId>org.springframework.boot</groupId> <artifactId>spring-boot-starter-jdbc</artifactId> </dependency> <dependency> <groupId>mysql</groupId> <artifactId>mysql-connector-java</artifactId> </dependency>
修改配置文件:
1 2 3 4 5 6 spring: datasource: driver-class-name: com.mysql.cj.jdbc.Driver url: jdbc:mysql: username: root password: 123456
创建表:
1 2 3 4 5 6 CREATE TABLE persistent_logins ( username VARCHAR (64 ) NOT NULL, series VARCHAR (64 ) PRIMARY KEY, token VARCHAR (64 ) NOT NULL, last_used TIMESTAMP NOT NULL )
MySecurityConfig配置类修改为:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 @Configuration public class MySecurityConfig extends WebSecurityConfigurerAdapter { @Autowired private MyAuthenticationSucessHandler authenticationSucessHandler; @Autowired private DataSource dataSource; @Autowired private UserDetailServiceImpl userDetailService; @Override protected void configure (HttpSecurity http) throws Exception { http.formLogin() .loginPage("/login.html" ) .loginProcessingUrl("/login" ) .successHandler(authenticationSucessHandler) .and() .rememberMe() .tokenRepository(persistentTokenRepository()) .tokenValiditySeconds(3600 ) .userDetailsService(userDetailService) .and() .authorizeRequests() .antMatchers("/login.html" ).permitAll() .anyRequest() .authenticated() .and().csrf().disable(); } @Bean public PasswordEncoder passwordEncoder () { return new BCryptPasswordEncoder(); } @Bean public PersistentTokenRepository persistentTokenRepository () { JdbcTokenRepositoryImpl jdbcTokenRepository = new JdbcTokenRepositoryImpl(); jdbcTokenRepository.setDataSource(dataSource); jdbcTokenRepository.setCreateTableOnStartup(false ); return jdbcTokenRepository; } }
点进JdbcTokenRepositoryImpl源码可以看到它对数据库的操作sql脚本。
登录页增加:
1 <input type="checkbox" name="remember-me" /> 记住我
重启项目,并打开F12观察Cookie:
登录后:
发现Cookie多了一个remember-me,查看数据库表,也自动插入了一条数据。
3-2.图形验证码 https://mrbird.cc/Spring-Security-ValidateCode.html
3-3.短信验证 https://mrbird.cc/Spring-Security-SmsCode.html
4.session管理 4-1.过期处理 首先,若不需要用Security来管理session,在configure方法加上:
1 2 3 .and() .sessionManagement() .sessionCreationPolicy(SessionCreationPolicy.STATELESS)
这样,Security就不会创建和使用session。
以下使用Security管理session。
配置session过期时间:
1 2 3 4 5 server: servlet: session: # 60秒 timeout: 60
这样,当session过期后,刷新界面会自动跳转到登录界面进行重新认证。
也可以自定义处理逻辑,如下:
configure方法:
1 2 3 4 5 6 ... .antMatchers("/login.html" , "、test/sessionInvalid" ).permitAll() ... .and() .sessionManagement() .invalidSessionUrl("/test/sessionInvalid" )
新建测试方法:
1 2 3 4 @RequestMapping ("/sessionInvalid" ) public String sessionInvalid () { return "session失效,需重新登录" ; }
启动项目并登录,等待60秒session过期后刷新页面,跳转到/sessionInvalid。
4-2.并发处理 一般网页登录都只支持一处登录,例如很多视频网站的会员号。那么,当同一账号进行二次登录时,会有两种处理策略。
第一种,第二个登录把第一个登录的踢下线(较多) 实现SessionInformationExpiredStrategy接口,重新onExpiredSessionDetected方法:
1 2 3 4 5 6 7 8 9 10 @Component public class MySessionInformationExpiredStrategy implements SessionInformationExpiredStrategy { @Override public void onExpiredSessionDetected (SessionInformationExpiredEvent sessionInformationExpiredEvent) throws IOException, ServletException { HttpServletResponse response = sessionInformationExpiredEvent.getResponse(); response.setStatus(HttpStatus.UNAUTHORIZED.value()); response.setContentType("application/json;charset=utf-8" ); response.getWriter().write("您的账号已经在别的地方登录,当前登录已失效。如果密码遭到泄露,请立即修改密码!" ); } }
configure方法:
1 2 3 4 5 6 .and() .sessionManagement() .invalidSessionUrl("/test/sessionInvalid" ) .maximumSessions(1 ) .expiredSessionStrategy(mySessionInformationExpiredStrategy) .and()
启动,用两个浏览器登录,当第二个浏览器登录完后,刷新第一个浏览器:
第二种,第一个登录后,阻止后续登录(较少) configure:
1 2 3 4 5 6 .and() .sessionManagement() .invalidSessionUrl("/test/sessionInvalid" ) .maximumSessions(1 ) .maxSessionsPreventsLogin(true ) .and()
这样,后续登录就无法成功。
5.权限控制 security提供权限控制功能。
首先在配置类MySecurityConfig上加上注解:
1 2 3 @Configuration @EnableGlobalMethodSecurity (prePostEnabled = true )public class MySecurityConfig extends WebSecurityConfigurerAdapter {
注:不加此注解直接在方法上使用@PreAuthorize无效,记得加上。
在UserDetailServiceImpl中修改loadUserByUsername方法模拟用户权限:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 @Configuration public class UserDetailServiceImpl implements UserDetailsService { @Autowired private PasswordEncoder passwordEncoder; @Override public UserDetails loadUserByUsername (String username) throws UsernameNotFoundException { MyUser user = new MyUser(); user.setUserName(username); user.setPassword("123456" ); if ("aaa" .equals(username)){ user.setIdentity("admin" ); } else { user.setIdentity("user" ); } List<GrantedAuthority> authorities = new ArrayList<>(); authorities = AuthorityUtils.commaSeparatedStringToAuthorityList(user.getIdentity()); user.setPassword(this .passwordEncoder.encode(user.getPassword())); return new User(username, user.getPassword(), true , true , true , true , authorities); } }
新增MyAccessDeniedHandler实现AccessDeniedHandler接口,重写handle方法。
1 2 3 4 5 6 7 8 9 @Component public class MyAccessDeniedHandler implements AccessDeniedHandler { @Override public void handle (HttpServletRequest httpServletRequest, HttpServletResponse httpServletResponse, AccessDeniedException e) throws IOException, ServletException { httpServletResponse.setStatus(HttpStatus.INTERNAL_SERVER_ERROR.value()); httpServletResponse.setContentType("application/json;charset=utf-8" ); httpServletResponse.getWriter().write("很抱歉,您没有该访问权限" ); } }
configure方法加上:
1 2 3 4 5 http.exceptionHandling() .accessDeniedHandler(myAccessDeniedHandler) .and() .formLogin() ...
测试方法:
1 2 3 4 5 @RequestMapping ("/tryAuthentication" ) @PreAuthorize ("hasAuthority('admin')" ) public String tryAuthentication () { return "您拥有admin权限,可以查看" ; }
启动用aaa登录成功后,访问/tryAuthentication:
用aaa1登录成功后再次访问:
6.其他使用方式 然而,实际项目中有可能不会使用到securitry提供的登录功能,毕竟有些以提供接口为主的项目可能并没有登录过程,但依然可以使用它来控制权限。
继承GenericFilterBean类并重写doFilter方法可以自定义各种逻辑处理。只要在configure的调用链中加上所需控制的地址,例如:
1 2 3 4 5 protected void configure (HttpSecurity http) throws Exception { http.authorizeRequests() .antMatchers("/test1/**" ).permitAll() .antMatchers("/test2/**" ).authenticated() ......
permitAll()表示不需要认证就可以访问,authenticated()表示需要认证。
然后,无论是哪一种,都会进入doFilter方法,那么你可以在方法里面用自己的逻辑来处理认证。比如,请求若带上了token并且验证成功,就相当于此次请求已得到认证。大致流程:
1 2 3 4 5 6 7 8 9 10 11 12 13 @Override public void doFilter (ServletRequest servletRequest, ServletResponse servletResponse, FilterChain filterChain) throws IOException, ServletException { HttpServletRequest httpServletRequest = (HttpServletRequest) servletRequest; String token = resolveToken(httpServletRequest); ......验证token ......设置权限authorities (对比上面loadUserByUsername中的new User) Authentication authentication = new UsernamePasswordAuthenticationToken(...这里可以传进token,authorities等); SecurityContextHolder.getContext().setAuthentication(authentication); filterChain.doFilter(servletRequest, servletResponse); }
得到认证的请求,自然可以正常访问接口,得不到认证的请求,若是permitAll(),也能继续访问,若是authenticated()就403了。当然,403也可以自定义返回,实现AuthenticationEntryPoint接口并在configure调用链上加上即可,与AccessDeniedHandler大同小异,具体用法可自行查阅资料。
事实上,无聊是通过登录的认证还是自定义的认证,其实就是让SecurityContext拥有了Authentication,用代码描述可能会更清晰:
1 SecurityContextHolder.getContext().setAuthentication(authentication);
只要security的上下文中存在用户认证信息,就表示已经验证了。
7.结语 那么,通过5.权限控制和6.其他使用方式,以下两个效果都可以实现了:
限制某些接口需要通过认证
限制某些接口不仅需要通过认证,还需要有对应权限
关于OAuth令牌发放机制和Spring Security OAuth2的具体实现待续。。。
附本次demo地址 。