🚀Github传送门:https://github.com/Khighness/spring-security
1. Start With Maven 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 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 <properties > <java.version > 1.8</java.version > <lombok.version > 1.18.16</lombok.version > <mysql.version > 8.0.20</mysql.version > <druid.version > 1.2.3</druid.version > <jwt.version > 0.9.1</jwt.version > <spring-boot.version > 2.2.2.RELEASE</spring-boot.version > <spring-cloud.version > Greenwich.SR1</spring-cloud.version > <mybatis.spring.boot.starter.version > 2.1.1</mybatis.spring.boot.starter.version > <mybatis-plus.boot.starter.version > 3.4.1</mybatis-plus.boot.starter.version > </properties > <dependencyManagement > <dependencies > <dependency > <groupId > org.projectlombok</groupId > <artifactId > lombok</artifactId > <version > ${lombok.version}</version > </dependency > <dependency > <groupId > mysql</groupId > <artifactId > mysql-connector-java</artifactId > <version > ${mysql.version}</version > </dependency > <dependency > <groupId > io.jsonwebtoken</groupId > <artifactId > jjwt</artifactId > <version > ${jwt.version}</version > </dependency > <dependency > <groupId > com.alibaba</groupId > <artifactId > druid</artifactId > <version > ${druid.version}</version > </dependency > <dependency > <groupId > com.alibaba</groupId > <artifactId > druid-spring-boot-starter</artifactId > <version > ${druid.version}</version > </dependency > <dependency > <groupId > org.mybatis.spring.boot</groupId > <artifactId > mybatis-spring-boot-starter</artifactId > <version > ${mybatis.spring.boot.starter.version}</version > </dependency > <dependency > <groupId > com.baomidou</groupId > <artifactId > mybatis-plus-boot-starter</artifactId > <version > ${mybatis-plus.boot.starter.version}</version > </dependency > <dependency > <groupId > org.springframework.boot</groupId > <artifactId > spring-boot-dependencies</artifactId > <version > ${spring-boot.version}</version > <type > pom</type > <scope > import</scope > </dependency > <dependency > <groupId > org.springframework.cloud</groupId > <artifactId > spring-cloud-dependencies</artifactId > <version > ${spring-cloud.version}</version > <type > pom</type > <scope > import</scope > </dependency > </dependencies > </dependencyManagement >
2. Spring Security Guide 📖概述
Spring Security是一款基于Spring的安全框架,主要包含认证和授权两大安全模块,和另外一款流行的安全框架相比,它拥有更加强大的功能。Spring Security也可以轻松的自定义扩展以满足各种需求,并且对常见的web安全攻击提供了防护支持。
2.1 快速开始 创建项目,导入依赖:
1 2 3 4 5 6 7 8 <dependency > <groupId > org.springframework.boot</groupId > <artifactId > spring-boot-starter-web</artifactId > </dependency > <dependency > <groupId > org.springframework.boot</groupId > <artifactId > spring-boot-starter-security</artifactId > </dependency >
创建一个REST接口:
1 2 3 4 5 6 7 @RestController public class HelloController { @GetMapping("/{name}") public String sayHello (@PathVariable(value = "name") String name) { return "Hello " + name + "!" ; } }
启动项目,访问http://localhost:8080/KHighness
默认用户名为user,默认密码会在IDE的控制台打印:
1 Using default security password: af1489e7-58ea-4e12-a5b3-b2e0aae5b2f9
输入正确的用户名和密码即可访问接口:
引入Spring Security依赖的时候,项目会默认开启认证:
1.X:默认Http basic认证
2.X:默认表单登录认证
默认用户:user
默认密码:随机生成
默认拦截:所有访问路径
2.2 两种认证 SpringSecurity2.x默认验证为表单验证,我们可以通过配置将表单认证修改为Http basic
认证。
创建一个配置类BrowserSecurityConfig
继承org.springframework.security.config.annotation.web.configuration.WebSecurityConfigurerAdapter
并且重写configure
方法。WebSecurityConfigurerAdapter
是有Spring Security提供的Web应用安全配置的适配器:
1 2 3 4 5 6 7 8 9 10 11 @Configuration public class BrowserSecurityConfig extends WebSecurityConfigurerAdapter { @Override public void configure(HttpSecurity httpSecurity) throws Exception { httpSecurity.httpBasic() // http basic认证 .and() .authorizeRequests() // 授权方式 .anyRequest() // 所有请求 .authenticated(); // 都需认证 } }
HttpSecurity
提供了这种链式的方法调用。以上配置认证方式为HTTP basic认证,并且所有请求都需要进行认证。
重启项目,再次访问http://localhost:8080/KHighness:
2.3 基本原理
Spring Security包含众多的过滤器,这些过滤器形成了一条安全过滤链,所有请求都必须通过这些过滤器后才能成功访问到资源。其中UsernamePasswordAuthenticationFilter
过滤器用于处理基于表单方式的登录认证,而BasicAuthenticationFilter
用于处理基于HTTP Basic
方式的登录验证,后面还可能包含一系列别的过滤器(可以通过相应配置开启)。在过滤器链的末尾是一个名为FilterSecurityInterceptor
的拦截器,用于判断当前请求身份认证是否成功,是否有相应的权限。当身份认证失败或者权限不足的时候便会抛出相应的异常。
ExceptionTranslateFilter
捕获并处理,所以我们在ExceptionTranslateFilter
过滤器用于处理了FilterSecurityInterceptor
抛出的异常并进行处理,比如需要身份认证时将请求重定向到相应的认证页面,当认证失败或者权限不足时返回相应的提示信息。
将认证方式改为表单认证,Debug证明这个过程。
在HelloController
上打个断点:
在FilterSecurityInterceptor
上的invoke
方法的super.beforeInvocation(fi)
上打个断点
当这行代码执行通过后,便可以调用下一行的doFilter
方法来真正调用/hello
服务,否则将抛出相应的异常。
当FilterSecurityInterceptor
抛出异常时,异常将由ExceptionTranslationFilter
捕获并处理,所以我们在ExceptionTranslationFilter
的doFilter
方法catch
代码块第一行打个断点:
等会模拟未登录直接访问/{name},所以应该抛出用户未登录的异常,所以接下来应该跳转到UsernamePasswordAuthenticationFilter
处理表单方式的用户认证,在UsernamePasswordAuthenticationFilter
的attemptAuthentication
方法上打个断点:
准备完毕,访问http://localhost:8080/KHighness。
代码第一步跳转到FilterSecurityInterceptor
的beforeInvocation
断点上:
往下执行,因为当前请求没有经过身份认证,所以抛出异常并被ExceptionTranslationFilter
捕获:
捕获异常后重定向到登录表单页面,当我们在表单登陆页面输入信息并点击Sign in后:
代码跳转到UsernamePasswordAuthenticationFilter
的attemptAuthentication
断点上:
判断用户名和密码是否正确之后,代码又跳回到FilterSecurityInterceptor
的beforeInvocation
断点上:
当认证通过时,FilterSecurityInterceptor
代码往下执行invoke
,然后代码最终跳转到上HelloController
上:
最后浏览器将显示Hello KHighness!
信息。
3. Spring Security Authentication 📖概述
Spring Security支持自定义认证过程,如处理用户信息获取逻辑,使用我们自定义的登录页面替换Spring Security默认的登录页及自定义登录成功或失败后的处理逻辑等。
3.1 替换默认登录页 创建项目,导入依赖:
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 <dependency > <groupId > org.projectlombok</groupId > <artifactId > lombok</artifactId > </dependency > <dependency > <groupId > mysql</groupId > <artifactId > mysql-connector-java</artifactId > </dependency > <dependency > <groupId > com.alibaba</groupId > <artifactId > druid</artifactId > </dependency > <dependency > <groupId > com.baomidou</groupId > <artifactId > mybatis-plus-boot-starter</artifactId > </dependency > <dependency > <groupId > com.alibaba</groupId > <artifactId > druid-spring-boot-starter</artifactId > </dependency > <dependency > <groupId > org.springframework.boot</groupId > <artifactId > spring-boot-starter-web</artifactId > </dependency > <dependency > <groupId > org.springframework.boot</groupId > <artifactId > spring-boot-starter-security</artifactId > </dependency > <dependency > <groupId > org.springframework.boot</groupId > <artifactId > spring-boot-starter-test</artifactId > </dependency > <dependency > <groupId > org.springframework.boot</groupId > <artifactId > spring-boot-starter-thymeleaf</artifactId > </dependency >
项目基本配置:
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 server: port: 8080 tomcat: uri-encoding: UTF-8 spring: application: name: CustomAuthenticationApplication thymeleaf: cache: false prefix: classpath:/templates/ suffix: .html mode: HTML5 encoding: UTF-8 datasource: druid: driver-class-name: com.mysql.cj.jdbc.Driver url: jdbc:mysql://localhost:3306/web?serverTimezone=GMT%2B8&useUnicode=true&characterEncoding=utf-8&useSSL=false&allowMultiQueries=true username: root password: KAG1823 mybatis-plus: mapper-locations: classpath*:/mapper/**/*.xml type-aliases-package: top.parak.entity configuration: map-underscore-to-camel-case: true log-impl: org.apache.ibatis.logging.stdout.StdOutImpl global-config: banner: false
在resources目录下新建templates目录,在templates目录创建登录页面login.html
:
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 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 <!DOCTYPE> <html > <head > <meta charset ="UTF-8" > <meta name ="viewport" content ="width=device-width, user-scalable=no, initial-scale=1.0, maximum-scale=1.0, minimum-scale=1.0" > <title > Login Page</title > <style type ="text/css" > body { margin : 0 ; padding : 0 ; height : 100vh ; background : #2F323A ; background-size : cover; } .form { background : #000 ; z-index : 1 ; font-family : "Candara" , sans-serif; position : absolute; top : 50% ; left : 50% ; transform : translate (-50% , -50% ); width : 300px ; padding : 0 45px 30px 45px ; text-align : center; border-radius : 10px ; opacity : 0.9 ; } .form h2 { color : #fff ; font-size : 28px ; font-weight : 500 ; text-align : center; text-transform : uppercase; } .form .icons i { color : #fff ; font-size : 25px ; margin : 0 30px 30px 30px ; transition : 0.8s ; transition-property : color, transform; } .form .icons i :hover { color : #06C5CF ; transform : scale (1.3 ); } .form input { outline : 0 ; background : none; font-size : 15px ; color : #fff ; text-align : center; width : 265px ; margin-bottom : 30px ; padding : 15px ; box-sizing : border-box; border : 2.5px solid #2E86DE ; border-radius : 25px ; transition : 0.5s ; transition-property : width; } .form input :hover { width : 300px ; } .form input :focus { width : 300px ; } .form button { outline : 0 ; background : none; color : #fff ; font-size : 14px ; text-transform : uppercase; width : 150px ; padding : 15px ; border : 2.5px solid #10AC84 ; border-radius : 25px ; cursor : pointer; transition : 0.5s ; transition-property : background, transform; } .form button :hover , .form button :active , .form button :focus { background : #10AC84 ; transform : scale (1.1 ); } .form .options { color : #bbb ; font-size : 14px ; margin : 20px 0 0 ; } .form .options a { text-decoration : none; color : #06C5CF ; } </style > </head > <body > <div class ="form" > <form class ="login-form" action ="/login" method ="post" > <h2 > Spring Security</h2 > <input type ="text" name ="username" placeholder ="Username" required ="required" > <input type ="password" name ="password" placeholder ="Password" required ="required" > <button type ="submit" name ="button" > Login</button > <p class ="options" > Not Registered? <a href ="/register" > Create an Account</a > </p > </form > </div > </body > </html >
创建认证配置类BrowserSecurityConfig
:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 @Configuration public class BrowserSecurityConfig extends WebSecurityConfigurerAdapter { @Override protected void configure (HttpSecurity httpSecurity) throws Exception { httpSecurity .formLogin() .loginPage("/authentication" ) .loginProcessingUrl("/login" ) .usernameParameter("username" ) .passwordParameter("password" ) .and() .authorizeRequests() .antMatchers("/authentication" ).permitAll() .anyRequest().authenticated() .and() .csrf().disable(); } }
创建一个Rest接口:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 @Controller public class AuthenticationController { @GetMapping("/authentication") public String authenticationLogin () { return "login" ; } @ResponseBody @GetMapping("/hello") public String hello () { return "Hello Khighness" ; } }
测试访问http://localhost:8080/hello,跳转到登录页面:
输入用户名(user)和密码(控制台打印)即可看到如下信息:
3.2 自定义认证过程 自定义认证的过程需要Spring Security提供的UserDetailService
接口,该接口只有一个抽象方法,源码如下:
1 2 3 public interface UserDetailsService { UserDetails loadUserByUsername (String var1) throws UsernameNotFoundException; }
loadUserByUsername
方法返回一个UserDetails
对象,该对象也是一个接口,包含一些用于描述用户信息的方法,用于与用户输入的用户名和密码进行对比认证,源码如下:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 public interface UserDetails extends Serializable { Collection<? extends GrantedAuthority > getAuthorities(); String getPassword () ; String getUsername () ; boolean isAccountNonExpired () ; boolean isAccountNonLocked () ; boolean isCredentialsNonExpired () ; boolean isEnabled () ; }
这些方法的含义如下:
方法
描述
getAuthorities
获取用户包含的权限,返回权限集合
getPassword
获取密码,返回String类型
getUsername
获取用户名,返回String类型
isAccountNonExpired
用于判断账户是否未过期,未过期返回true反正返回false
isAccountNonLocked
用于判断账户是否未锁定,未锁定返回true反正返回false
isCredentialsNonExpired
用于判断账户凭证(即密码)是否未过期,未过期返回true反正返回false
isEnabled
用于判断用户是否可用,可用返回true反正返回false
创建数据库:
用户名
密码
角色
Khighness
KAG1823
admin
FlowerK
KAG1823
manager
RubbishK
KAG1823
normal
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 CREATE TABLE `user ` ( `id` int NOT NULL AUTO_INCREMENT, `username` varchar (20 ) CHARACTER SET utf8 COLLATE utf8_bin NOT NULL , `password` varchar (80 ) CHARACTER SET utf8 COLLATE utf8_bin NOT NULL , `status` tinyint DEFAULT NULL , PRIMARY KEY (`id`) ) ENGINE= InnoDB AUTO_INCREMENT= 2 DEFAULT CHARSET= utf8 COLLATE = utf8_bin; INSERT INTO `user ` VALUES (1 , 'Khighness' , '$2a$10$lACRBH58EGnTGHGrt2kya.OaF5vzpqkYR3Hx604FkWw0Lr6tu0goO' , 1 );INSERT INTO `user ` VALUES (2 , 'FlowerK' , '$2a$10$lACRBH58EGnTGHGrt2kya.OaF5vzpqkYR3Hx604FkWw0Lr6tu0goO' , 1 );INSERT INTO `user ` VALUES (3 , 'RubbishK' , '$2a$10$lACRBH58EGnTGHGrt2kya.OaF5vzpqkYR3Hx604FkWw0Lr6tu0goO' , 1 );CREATE TABLE `role` ( `id` int NOT NULL AUTO_INCREMENT, `role_name` varchar (255 ) COLLATE utf8_bin DEFAULT NULL , PRIMARY KEY (`id`) ) ENGINE= InnoDB AUTO_INCREMENT= 4 DEFAULT CHARSET= utf8 COLLATE = utf8_bin; INSERT INTO `role` VALUES (1 , 'admin' );INSERT INTO `role` VALUES (2 , 'manager' );INSERT INTO `role` VALUES (3 , 'normal' );CREATE TABLE `user_role` ( `id` int NOT NULL AUTO_INCREMENT, `user_id` int NOT NULL , `role_id` int NOT NULL , PRIMARY KEY (`id`), KEY `user_id` (`user_id`), KEY `role_id` (`role_id`), CONSTRAINT `role_id` FOREIGN KEY (`role_id`) REFERENCES `role` (`id`), CONSTRAINT `user_id` FOREIGN KEY (`user_id`) REFERENCES `user ` (`id`) ) ENGINE= InnoDB AUTO_INCREMENT= 2 DEFAULT CHARSET= utf8 COLLATE = utf8_bin; INSERT INTO `user_role` VALUES (1 , 1 , 1 );INSERT INTO `user_role` VALUES (2 , 2 , 2 );INSERT INTO `user_role` VALUES (3 , 3 , 3 );
首先三个创建实体类。
定义用户MyUser
:
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 @Data @AllArgsConstructor @NoArgsConstructor @TableName("user") public class MyUser implements Serializable { private static final long serialVersionUID = 1753000091845565507L ; @TableId(value = "id", type = IdType.AUTO) private int id; @TableField(value = "username") private String username; @TableField(value = "password") private String password; @TableField(exist = false) private boolean accountNonExpired = true ; @TableField(exist = false) private boolean accountNonLocked= true ; @TableField(exist = false) private boolean credentialsNonExpired= true ; @TableField(exist = false) private boolean enabled= true ; }
定义角色MyRole
:
1 2 3 4 5 6 7 8 9 10 11 12 13 @Data @AllArgsConstructor @NoArgsConstructor @TableName("role") public class MyRole implements Serializable { private static final long serialVersionUID = 7565000980394639717L ; @TableId(value = "id", type = IdType.AUTO) private int id; @TableField(value = "role_name") private String roleName; }
定义用户角色关系UserToRole
:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 @Data @AllArgsConstructor @NoArgsConstructor @TableName("user_role") public class UserToRole implements Serializable { private static final long serialVersionUID = 252063821059124209L ; @TableId(value = "id", type = IdType.AUTO) private int id; @TableField(value = "user_id") private int userId; @TableField(value = "role_id") private int roleId; }
接着以上三个实体类的持久层接口全部继承Mybatis-plus
的BaseMapper
。
然后在BrowserSecurityConfig
中添加Bean
,以实现BCR加密:
1 2 3 4 @Bean public PasswordEncoder passwordEncoder () { return new BCryptPasswordEncoder (); }
重点来了,自定义认证,创建CustomUserDetailService
实现UserDetailsService
接口:
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 @Slf4j @Configuration public class CustomUserDetailService implements UserDetailsService { @Resource private UserMapper userMapper; @Resource private RoleMapper roleMapper; @Resource private UserToRoleMapper userToRoleMapper; @Override public UserDetails loadUserByUsername (String username) throws UsernameNotFoundException { if (username != null ) { log.info("登录用户名 => [{}]" , username); } QueryWrapper<MyUser> userQueryWrapper = new QueryWrapper <>(); userQueryWrapper.eq("username" , username); MyUser user = userMapper.selectOne(userQueryWrapper); QueryWrapper<UserToRole> userToRoleQueryWrapper = new QueryWrapper <>(); userToRoleQueryWrapper.eq("user_id" , user.getId()); UserToRole userToRole = userToRoleMapper.selectOne(userToRoleQueryWrapper); int roleId = userToRole.getRoleId(); QueryWrapper<MyRole> roleQueryWrapper = new QueryWrapper <>(); roleQueryWrapper.eq("id" , roleId); MyRole role = roleMapper.selectOne(roleQueryWrapper); return new User (username, user.getPassword(), user.isEnabled(), user.isAccountNonExpired(), user.isCredentialsNonExpired(), user.isAccountNonLocked(), AuthorityUtils.commaSeparatedStringToAuthorityList(role.getRoleName())); } }
测试访问http://localhost:8080/hello,输入用户名(Khighness)和密码(KAG1823)即可访问成功。
3.3 处理成功和失败 Spring Security有一套默认的处理登录成功和失败的方法:当用户登录成功时,页面会跳转会引发登录的请求,比如在未登录的情况下访问http://localhost:8080/hello,页面会跳转到登录页,登录成功后再跳转回来;登录失败时则是跳转到Spring Security默认的错误提示页面。下面我们通过一些自定义配置来替换这套默认的处理机制。
(1)自定义登录成功逻辑
要改变默认的处理成功逻辑很简单,只需要实现org.springframework.security.web.authentication.AuthenticationSuccessHandler
接口的onAuthenticationSuccess
方法即可。
登录成功返回认证信息:
1 2 3 4 5 6 7 8 9 @Component public class MyAuthenticationSucessHandler implements AuthenticationSuccessHandler { private RedirectStrategy redirectStrategy = new DefaultRedirectStrategy (); @Override public void onAuthenticationSuccess (HttpServletRequest request, HttpServletResponse response, Authentication authentication) throws IOException { redirectStrategy.sendRedirect(request, response, "/success" ); } }
在Rest接口中添加/success
接口:
1 2 3 4 5 @ResponseBody @GetMapping("/success") public Object success (Authentication authentication) { return authentication; }
(2)自定义登录失败逻辑
登录失败返回错误信息:
1 2 3 4 5 6 7 8 9 10 11 12 13 @Component public class MyAuthenticationFailureHandler implements AuthenticationFailureHandler { @Autowired private ObjectMapper mapper; @Override public void onAuthenticationFailure (HttpServletRequest request, HttpServletResponse response, AuthenticationException exception) throws IOException { response.setStatus(HttpStatus.INTERNAL_SERVER_ERROR.value()); response.setContentType("application/json;charset=utf-8" ); response.getWriter().write(mapper.writeValueAsString(exception.getMessage())); } }
最后修改BrowserSecurityConfig
:
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 @Configuration public class BrowserSecurityConfig extends WebSecurityConfigurerAdapter { @Autowired private MyAuthenticationSucessHandler authenticationSucessHandler; @Autowired private MyAuthenticationFailureHandler authenticationFailureHandler; @Bean public PasswordEncoder passwordEncoder () { return new BCryptPasswordEncoder (); } @Override protected void configure (HttpSecurity httpSecurity) throws Exception { httpSecurity .formLogin() .loginPage("/authentication" ) .loginProcessingUrl("/login" ) .usernameParameter("username" ) .passwordParameter("password" ) .successHandler(authenticationSucessHandler) .failureHandler(authenticationFailureHandler) .and() .authorizeRequests() .antMatchers("/authentication" ).permitAll() .anyRequest().authenticated() .and() .csrf().disable(); } }
启动项目,测试访问:http://localhost:8080/hello
登录成功后返回如下信息:
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 { "authorities" : [ { "authority" : "admin" } ] , "details" : { "remoteAddress" : "0:0:0:0:0:0:0:1" , "sessionId" : null } , · "authenticated" : true , "principal" : { "password" : null , "username" : "Khighness" , "authorities" : [ { "authority" : "admin" } ] , "accountNonExpired" : true , "accountNonLocked" : true , "credentialsNonExpired" : true , "enabled" : true } , "credentials" : null , "name" : "Khighness" }
像password
,credentials
这些敏感信息,Spring Security已经将其屏蔽。
4. Spring Security RememberMe 💠说明
这一节的代码在上一节的基础上修改。
📖概述
SpringSecurity记住我功能的实现过程:用户勾选了记住我选项并登录成功后,SpringSecurity会生成一个token,并持久化到数据库,并且生成一个与该token相对应的cookie返回给浏览器。当用户过段时间再次访问系统时,如果该cookie没有过期,SpringSecurity便会根据cookie包含的信息从数据库获取相应的token信息,然后帮用户自动完成登录操作。
4.1 登录修改 在登录页面中添加记住我复选框:
1 <p > <input type ="checkbox" name ="remember-me" /> remember me</p >
注意,name
必须为remember-me
。
4.2 数据库表 在数据库中新建表,存储token:
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 )
4.3 后端逻辑 在配置类中配置token持久化对象:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 @Configuration @EnableGlobalMethodSecurity(prePostEnabled = true) public class BrowserSecurityConfig extends WebSecurityConfigurerAdapter { ... @Qualifier("customUserDetailService") @Autowired private UserDetailsService userDetailsService; @Autowired private DataSource dataSource; @Bean public PersistentTokenRepository persistentTokenRepository () { JdbcTokenRepositoryImpl jdbcTokenRepository = new JdbcTokenRepositoryImpl (); jdbcTokenRepository.setDataSource(dataSource); jdbcTokenRepository.setCreateTableOnStartup(false ); return jdbcTokenRepository; } }
最后在Spring Security的认证流程中启用记住我的功能,即在配置类的configure
方法中开启:
1 2 3 4 5 6 7 8 9 10 11 12 @Override protected void configure (HttpSecurity httpSecurity) throws Exception { httpSecurity ... .and() .rememberMe() .tokenValiditySeconds(3600 ) .tokenRepository(persistentTokenRepository()) .userDetailsService(userDetailsService) ; }
4.4 重新测试 重启项目,登录页面如下:
比较难看,无伤大雅。勾选并登录,可以看到网页多了remember-me的cookie:
查看数据库表peristent_logins
:
可以看到token信息已经成功持久化了。在cookie失效之前,无论是重开浏览器还是重启项目,用户都无需再次登录就可以访问。
5. Spring Security Authorization 💠说明
这一节的code依然承接上一节。
5.1 安全注解 配合授权注解使用,Spring Security提供了三种不同的安全注解:
Spring Security自带的@Secured
JSR-250的@RolesAllowed
表达式驱动注解:@PreAuthrize
、@PostAuthorize
、@PreFilter
和@PostFilter
。
5.2 相关配置 要开启这些注解,只需要在Spring Security配置类中添加如下注解:
1 2 3 4 5 @Configuration @EnableGlobalMethodSecurity(prePostEnabled = true) public class BrowserSecurityConfig extends WebSecurityConfigurerAdapter { ... }
自定义一个处理器,处理权限不足的情况:
1 2 3 4 5 6 7 8 9 10 @Component public class MyAuthenticationAccessDeniedHandler implements AccessDeniedHandler { @Override public void handle (HttpServletRequest request, HttpServletResponse response, AccessDeniedException e) throws IOException, ServletException { response.setStatus(HttpStatus.INTERNAL_SERVER_ERROR.value()); response.setContentType("application/json;charset=utf-8" ); response.getWriter().write("很抱歉,您没有访问权限。" + e.getMessage()); } }
并将这个处理器添加到配置链中:
1 2 3 4 5 6 7 8 9 10 11 12 13 @Autowired private MyAuthenticationAccessDeniedHandler authenticationAccessDeniedHandler;@Override protected void configure (HttpSecurity httpSecurity) throws Exception { httpSecurity ... .and() .exceptionHandling() .accessDeniedHandler(authenticationAccessDeniedHandler) ; }
5.3 权限测试 编写两个接口,一个接口只允许admin和manager访问,另一个接口只允许normal访问。
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 @RestController public class AuthorizationController { @GetMapping("/auth/manage") @PreAuthorize("hasAnyAuthority('admin', 'manager')") public String manage () { return "您是Admin或者manager,可以访问" ; } @GetMapping("/auth/normal") @PreAuthorize("hasAuthority('normal')") public String normal () { return "您是普通角色,可以访问" ; } }
启动项目,访问http://localhost:8080/authentication
登录admin账号(Khighness,KAG1823)
测试访问:http://localhost:8080/auth/manage
测试访问:http://localhost:8080/auth/normal
6. Spring Security OAuth2 Guide 🌐参考
📖概述
OAuth是一种用来规范令牌(Token)发放的授权机制,主要包含了四种授权模式:
授权码模式(authorization code)
简化模式(implicit)
密码模式(resource owner password credentials)
客户端模式(client credentials)
Spring Security OAuth2对这四种授权模式进行了实现。
6.1 名词学习 在了解这四种授权模式之前,我们先学习一些和OAuth相关的名词。
举个社交登录的例子,比如我在浏览器中使用QQ账号登录虎牙直播,在这个过程中可以提取出以下几个名词:
Third-party application
第三方应用程序 => 虎牙直播
HTTP service
HTTP服务提供商 => QQ
Resource Owner
资源所有者 => 用户,我
User Agent
用户代理 => 浏览器
Authorization server
认证服务器 => QQ提供的第三方登录服务
Resource server
资源服务器 => 虎牙直播提供的服务,高清直播、弹幕发送
认证服务器和资源服务器可以在同一台服务器上,比如前后端分离的服务后台,它提供认证服务(提供令牌),客户端通过令牌来从资源服务器获取服务;它们也可以不在一台服务器上,比如第三方登录的案例。
6.2 运行流程
(A)用户打开客户端以后,客户端要求用户给予授权
(B)用户同意给予客户端授权
(C)客户端使用上一步获得的授权,向认证服务器申请令牌
(D)认证服务器对客户端进行认证后,确认无误,统一发放令牌
(E)客户端使用令牌,向资源服务器申请获取资源
(F)资源服务器确认令牌无误,同意向客户端开放资源
6.3 四种模式 (1)授权码模式
授权码模式是功能最完整、流程最严密的授权模式。
它的特点是通过客户端的后台服务器,与服务提供商的认证服务器进行互动。
流程如下:
(A)客户端将用户导向认证服务器
(B)用户决定是否给客户端授权
(C)同意授权后,认证服务器将用户导向客户端提供的URL,并附上授权码
(D)客户端通过重定向URL和授权码到认证服务器换取令牌
(E)校验无误后发放令牌
在A步骤中,客户端申请认证的URI,包含以下参数:
response_type
:表示授权类型,必选项,此处的值固定为code
,标识授权码模式
client_id
:表示客户端ID,必选项
redirect_uri
:表示重定向URI,可选项
scope
:表示申请的权限范围,可选项
state
:表示客户端的当前状态,可以指定任意值,认证服务器会原封不动地返回这个值
在C步骤中,服务器回应客户端的URI,包含以下参数:
code
:表示授权码,必选项。该码的有效期应该很短,通常设为10分钟,客户端只能使用该码一次,否则会被授权服务器拒绝。该码与客户端ID和重定向URI,是一一对应关系。
state
:如果客户端的请求中包含这个参数,认证服务器的回应也必须一模一样包含这个参数。
在D步骤中,客户端向认证服务器申请令牌的HTTP请求,包含以下参数:
grant_type
:表示使用的授权模式,必选项,此处的值固定为authorization_code
code
:表示上一步获得的授权码,必选项
redirect_uri
:表示重定向URI,必选项,且必须与A步骤中的该参数值保持一致
client_id
:表示客户端ID,必选项
在E步骤中,认证服务器发送的HTTP回复,包含以下参数:
access_token
:表示访问令牌,必选项。
token_type
:表示令牌类型,该值大小写不敏感,必选项,可以是bearer类型或mac类型。
expires_in
:表示过期时间,单位为秒。如果省略该参数,必须其他方式设置过期时间。
refresh_token
:表示更新令牌,用来获取下一次的访问令牌,可选项。
scope
:表示权限范围,如果与客户端申请的范围一致,此项可省略。
(2)简化模式
简化模式不通过第三方应用程序的服务器,直接在浏览器中向认证服务器申请令牌,跳过了“授权码”这个步骤。
它的特点是所有步骤在浏览器中完成,令牌对访问者是可见的,且客户端不需要认证。
流程如下:
(A)客户端将用户导向认证服务器。
(B)用户决定是否给于客户端授权。
(C)假设用户给予授权,认证服务器将用户导向客户端指定的”重定向URI”,并在URI的Hash部分包含了访问令牌。
(D)浏览器向资源服务器发出请求,其中不包括上一步收到的Hash值。
(E)资源服务器返回一个网页,其中包含的代码可以获取Hash值中的令牌。
(F)浏览器执行上一步获得的脚本,提取出令牌。
(G)浏览器将令牌发给客户端。
在A步骤中,客户端发出的HTTP请求,包含以下参数:
response_type
:表示授权类型,此处的值固定为token
,必选项
client_id
:表示客户端的ID,必选项
redirect_uri
:表示重定向的URI,可选项
scope
:表示权限范围,可选项
state
:表示客户端的当前状态,可以指定任意值,认证服务器会原封不动地返回这个值
在C步骤中,认证服务器回应客户端的URI,包含以下参数:
access_token
:表示访问令牌,必选项
token_type
:表示令牌类型,该值大小写不敏感,必选项
expires_in
:表示过期时间,单位为秒。如果省略该参数,必须其他方式设置过期时间
scope
:表示权限范围,如果与客户端申请的范围一致,此项可省略
state
:如果客户端的请求中包含这个参数,认证服务器的回应也必须一模一样包含这个参数
(3)密码模式
密码模式中,用户向客户端提供自己的用户名和密码。客户端使用这些信息,向认证服务器索要授权。
在这种模式下,用户必须把自己的密码给客户端,但是客户端不得存储密码。这通常用在用户对客户端高度信任的情况下,比如客户端是操作系统的一部分,或者由一个著名公司出品。而认证服务器只有在其他授权模式无法执行的情况下,才能考虑使用这种模式。
流程如下:
(A)用户向客户端提供用户名和密码
(B)客户端将用户名和密码发给认证服务器,向后者请求令牌
(C)认证服务器确认无误后,向客户端提供访问令牌
在B步骤中,客户端发出的HTTP请求,包含以下参数:
grant_type
:表示授权类型,此处的值固定为”password”,必选项
username
:表示用户名,必选项
password
:表示用户的密码,必选项
scope
:表示权限范围,可选项
(4)客户端模式
客户端模式指客户端以自己的名义,而不是以用户的名义,向服务提供商进行认证。
严格地说,客户端模式并不属于OAuth框架所要解决的问题。在这种模式中,用户直接向客户端注册,客户端以自己的名义要求”服务提供商”提供服务,其实不存在授权问题。
流程如下:
(A)客户端向认证服务器进行身份认证,并要求一个访问令牌
(B)认证服务器确认无误后,向客户端提供访问令牌
在A步骤中,客户端发出的HTTP请求,包含以下参数:
grant_type
:表示授权类型,此处的值固定为clientcredentials
,必选项
scope
:表示权限范围,可选项
6.4 Spring Security OAuth2 Spring框架对OAuth2协议进行了实现,下面学习授权码模式和密码模式在Spring Security OAuth2相关框架的使用。
Spring Security OAuth2主要包含认证服务器和资源服务器这两大块的实现:
认证服务器主要包含了四种授权模式的实现和Token的生成与存储,我们也可以在认证服务器中自定义获取Token的方式;资源服务器主要是在Spring Security的过滤链上加了OAuth2AuthenticationProcessFilter过滤器,即使用OAuth2协议发放令牌认证的方式来保证我们的资源。
创建项目,引入依赖:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 <dependency > <groupId > org.projectlombok</groupId > <artifactId > lombok</artifactId > </dependency > <dependency > <groupId > org.springframework.boot</groupId > <artifactId > spring-boot-starter-web</artifactId > </dependency > <dependency > <groupId > org.springframework.cloud</groupId > <artifactId > spring-cloud-starter</artifactId > </dependency > <dependency > <groupId > org.springframework.cloud</groupId > <artifactId > spring-cloud-starter-oauth2</artifactId > </dependency > <dependency > <groupId > org.springframework.cloud</groupId > <artifactId > spring-cloud-starter-security</artifactId > </dependency > <dependency > <groupId > org.apache.commons</groupId > <artifactId > commons-lang3</artifactId > </dependency >
先定义一个MyUser
对象:
1 2 3 4 5 6 7 8 9 10 11 12 13 @Data @AllArgsConstructor @NoArgsConstructor public class MyUser implements Serializable { private static final long serialVersionUID = -3088964382959687717L ; private String userName; private String password; private boolean accountNonExpired = true ; private boolean accountNonLocked= true ; private boolean credentialsNonExpired= true ; private boolean enabled= true ; }
接着定义CustomUserDetailService
实现UserDetailService
接口:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 @Service public class CustomUserDetailService implements UserDetailsService { @Autowired private PasswordEncoder passwordEncoder; @Override public UserDetails loadUserByUsername (String username) throws UsernameNotFoundException { MyUser user = new MyUser (); user.setUserName(username); user.setPassword(this .passwordEncoder.encode("KAG1823" )); return new User (username, user.getPassword(), user.isEnabled(), user.isAccountNonExpired(), user.isCredentialsNonExpired(), user.isAccountNonLocked(), AuthorityUtils.commaSeparatedStringToAuthorityList("admin" )); } }
这里的逻辑是登录账号的用户名无所谓,密码必须是KAG1823
,并且拥有admin权限。
接下来创建认证服务器,并且在里面定义UserDetailService
需要用到的PasswordEncoder
。
创建认证服务器,只需要在Spring Security的配置类上使用@EnableAuthorizationServer
注解标注即可。
创建AuthorizationServerConfig
,代码如下所示:
1 2 3 4 5 6 7 8 9 10 @Configuration @EnableAuthorizationServer public class AuthorizationServerConfig extends WebSecurityConfigurerAdapter { @Bean public PasswordEncoder passwordEncoder () { return new BCryptPasswordEncoder (); } }
启动项目,会发现控制台打印了随机分配的client-id
和client-secret
:
1 2 security.oauth2.client.client-id = 0 b560e22-4148-4bed-8c0d-e0f94813692b security.oauth2.client.client-secret = 468 daba6-e1e5-4c94-8a19-f092a81d7af5
为了方便测试,我们可以手动指定这两个值。
在配置文件application.yml
中添加如下配置:
1 2 3 4 5 security: oauth2: client: client-id: k client-secret: parak
重启项目,发现控制台输出:
1 2 security.oauth2.client.client-id = k security.oauth2.client.client-secret = ****
说明替换成功。
启动项目,访问:http://localhost:8080/oauth/authorize?response_type=code&client_id=k&redirect_uri=http://parak.top&scope=all&state=hello
输入随意用户名,密码KAG1823,登录认证后,页面如下:
原因是上面指定的redirect_uri必须同时在配置文件中指定,我们往application.yml添加配置:
1 2 3 4 5 6 security: oauth2: client: client-id: k client-secret: parak registered-redirect-uri: http:
重启项目,重新执行以上步骤,登录成功后跳转到授权页面:
选择同意Approve,然后点击Authorize授权按钮,页面跳转到了我们指定的redirect_uri,并且带上了授权码信息:
可以看到授权码为:Ftlxyf,然后我们就可以使用这个授权码从认证服务器获取令牌Token了。
使用postman发送请求:http://localhost:8080/oauth/token
其中五个参数即为申请令牌的参数,grant_type
为授权类型,此处固定为authorization_code
,code
为上一步获得的授权码,client_id
为客户端id,redirect_uri
为重定向URI,scope
为申请权限范围。
此外,我们还需要在请求头中填写如下内容:
其中,key为Authorization
,value为Basic Base64(client_id:client-secret)
Base64工具:http://tool.chinaz.com/Tools/Base64.aspx
参数填写无误后,发送请求边获取到令牌:
1 2 3 4 5 6 7 { "access_token" : "ef19f338-651b-43ae-9cea-66ec6e208d0c" , "token_type" : "bearer" , "refresh_token" : "6761af90-c79e-4023-b23a-3a9c4210e1d4" , "expires_in" : 43199 , "scope" : "all" }
一个授权码只能获取一次令牌,如果再次获取,将返回:
1 2 3 4 { "error" : "invalid_grant" , "error_description" : "Invalid authorization code: Ftlxyf" }
为什么要配置资源服务器呢?先测试一下在没有定义资源服务器的时候,我们使用Token去获取资源会发生什么。
定义一个Rest接口:
1 2 3 4 5 6 7 8 @RestController public class IndexController { @GetMapping("/index") public Object index (Authentication authentication) { return authentication; } }
启动项目,使用密码模式获取令牌,参数在上述四大模式中已经提到:
然后使用该令牌访问/index
,value为token_type access_token
,结果如下:
虽然令牌是正确的,但是并无法访问/index
,所以我们必须配置资源服务器,让客户端可以通过合法的令牌来获取资源。
资源服务器的配置也很简单,只需要在配置类上使用@EnableResourceServer
注解即可:
1 2 3 4 @Configuration @EnableResourceServer public class ResourceServerConfig {}
重启服务,重复上面步骤,再次访问/index
,结果如下:
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 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 { "authorities" : [ { "authority" : "admin" } ] , "details" : { "remoteAddress" : "0:0:0:0:0:0:0:1" , "sessionId" : null , "tokenValue" : "5a5b41c7-80d1-4496-9b6b-a8151db23160" , "tokenType" : "bearer" , "decodedDetails" : null } , "authenticated" : true , "userAuthentication" : { "authorities" : [ { "authority" : "admin" } ] , "details" : { "grant_type" : "password" , "username" : "Khighness" , "scope" : "all" } , "authenticated" : true , "principal" : { "password" : null , "username" : "Khighness" , "authorities" : [ { "authority" : "admin" } ] , "accountNonExpired" : true , "accountNonLocked" : true , "credentialsNonExpired" : true , "enabled" : true } , "credentials" : null , "name" : "Khighness" } , "oauth2Request" : { "clientId" : "k" , "scope" : [ "all" ] , "requestParameters" : { "grant_type" : "password" , "username" : "Khighness" , "scope" : "all" } , "resourceIds" : [ ] , "authorities" : [ { "authority" : "ROLE_USER" } ] , "approved" : true , "refresh" : false , "redirectUri" : null , "responseTypes" : [ ] , "extensions" : { } , "grantType" : "password" , "refreshTokenRequest" : null } , "clientOnly" : false , "credentials" : "" , "principal" : { "password" : null , "username" : "Khighness" , "authorities" : [ { "authority" : "admin" } ] , "accountNonExpired" : true , "accountNonLocked" : true , "credentialsNonExpired" : true , "enabled" : true } , "name" : "Khighness" }
在同时定义了认证服务器和资源服务器后,再去使用授权码模式获取令牌可能会遇到Full authentication is required to access this resource
的问题,这时候只要确保认证服务器先于资源服务器配置即可,比如在认证服务器的配资类上使用@Order(1)
标注,在资源服务器的配置类上使用@Order(2)
标注。
7. Spring Security OAuth2 Custom 📖概述
这一节主要实现通过自定义的用户名密码和邮箱短信验证码的方式来获取令牌。
7.1 用户名密码 继承上一节的项目,资源服务器上加入一些基本配置,将表单登录的URL设置为/login
,同时加入处理登录成功和失败的逻辑:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 @Configuration @EnableResourceServer public class ResourceServerConfig extends ResourceServerConfigurerAdapter { @Autowired private MyAuthenticationSuccessHandler authenticationSuccessHandler; @Autowired private MyAuthenticationFailureHandler authenticationFailureHandler; @Override public void configure (HttpSecurity http) throws Exception { http.formLogin() .loginProcessingUrl("/login" ) .successHandler(authenticationSuccessHandler) .failureHandler(authenticationFailureHandler) .and() .authorizeRequests() .anyRequest() .authenticated() .and() .csrf().disable() ; } }
处理登录失败的逻辑很简单,直接返回错误提示:
1 2 3 4 5 6 7 8 9 10 11 12 @Component public class MyAuthenticationFailureHandler implements AuthenticationFailureHandler { @Autowired private ObjectMapper objectMapper; @Override public void onAuthenticationFailure (HttpServletRequest request, HttpServletResponse response, AuthenticationException e) throws IOException, ServletException { response.setStatus(HttpStatus.INTERNAL_SERVER_ERROR.value()); response.setContentType("application/json;charset=utf-8" ); response.getWriter().write(objectMapper.writeValueAsString(e.getMessage())); } }
处理登录成功的逻辑是关键,Spring Security OAuth2的令牌产生流程如下:
参考这个流程,来实现在登录成功处理器MyAuthenticationSuccessHandler
里生成令牌并返回:
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 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 @Slf4j @Component public class MyAuthenticationSuccessHandler implements AuthenticationSuccessHandler { @Autowired private ObjectMapper objectMapper; @Autowired private ClientDetailsService clientDetailsService; @Qualifier("defaultAuthorizationServerTokenServices") @Autowired private AuthorizationServerTokenServices authorizationServerTokenServices; @Override public void onAuthenticationSuccess (HttpServletRequest request, HttpServletResponse response, Authentication authentication) throws IOException, ServletException { String header = request.getHeader("Authorization" ); if (header == null || !header.startsWith("Basic " )) { throw new UnapprovedClientAuthenticationException ("No client information was found in the request header" ); } String[] tokens = this .extractAndDecodeHeader(header); String clientId = tokens[0 ], clientSecret = tokens[1 ]; TokenRequest tokenRequest = null ; ClientDetails clientDetails = clientDetailsService.loadClientByClientId(clientId); if (clientDetails == null ) { throw new UnapprovedClientAuthenticationException ("No client details was found for clientId " + clientId); } else if (!StringUtils.equals(clientDetails.getClientSecret(), clientSecret)) { throw new UnapprovedClientAuthenticationException ("The client secret is invalid" ); } else { tokenRequest = new TokenRequest (new HashMap <>(), clientId, clientDetails.getScope(), "custom" ); } OAuth2Request auth2Request = tokenRequest.createOAuth2Request(clientDetails); OAuth2Authentication auth2Authentication = new OAuth2Authentication (auth2Request, authentication); OAuth2AccessToken token = authorizationServerTokenServices.createAccessToken(auth2Authentication); log.info("[{}]登录成功" , authentication.getName()); response.setContentType("application/json;charset=utf-8" ); response.getWriter().write(objectMapper.writeValueAsString(token)); } private String[] extractAndDecodeHeader(String header) { byte [] base64Token = header.substring(6 ).getBytes(StandardCharsets.UTF_8); byte [] decoded; try { decoded = Base64.getDecoder().decode(base64Token); } catch (IllegalArgumentException e) { throw new BadCredentialsException ("Failed to decode basic authentication token" ); } String token = new String (decoded, StandardCharsets.UTF_8); int index = token.indexOf(":" ); if (index == -1 ) { throw new BadCredentialsException ("The basic authentication token is invalid" ); } else { return new String []{token.substring(0 , index), token.substring(index + 1 )}; } } }
启动项目,使用postman发送登录请求:http://localhost:8080/login?username=Khighness&password=KAG1823
同时在请求头中带上Authorization:Basic azpwYXJhaw==
成功获取到令牌后,再访问/index
接口:
7.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 31 32 33 34 35 36 <dependency > <groupId > org.projectlombok</groupId > <artifactId > lombok</artifactId > </dependency > <dependency > <groupId > org.springframework.boot</groupId > <artifactId > spring-boot-starter-web</artifactId > </dependency > <dependency > <groupId > org.springframework.boot</groupId > <artifactId > spring-boot-starter-data-redis</artifactId > </dependency > <dependency > <groupId > org.springframework.boot</groupId > <artifactId > spring-boot-starter-mail</artifactId > </dependency > <dependency > <groupId > org.springframework.boot</groupId > <artifactId > spring-boot-starter-test</artifactId > </dependency > <dependency > <groupId > org.springframework.cloud</groupId > <artifactId > spring-cloud-starter</artifactId > </dependency > <dependency > <groupId > org.springframework.cloud</groupId > <artifactId > spring-cloud-starter-oauth2</artifactId > </dependency > <dependency > <groupId > org.springframework.cloud</groupId > <artifactId > spring-cloud-starter-security</artifactId > </dependency > <dependency > <groupId > org.apache.commons</groupId > <artifactId > commons-lang3</artifactId > </dependency >
配置security,redis和mail:
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 security: oauth2: client: client-id: k client-secret: parak registered-redirect-uri: http://parak.top spring: redis: host: 127.0 .0 .1 password: KAG1823 database: 0 mail: username: <邮箱> password: <邮箱授权码> host: <邮件STMP服务器> port: 465 properties: mail: ssl: enable: true smtp: auth: true starttls: enable: true required: true socketFactory: port: 465 class: javax.net.ssl.SSLSocketFactory fallback: false
在Spring Security中,使用用户名密码认证的过程大致如下图左流程所示。
Spring Security使用UsernamePasswordAuthenticationFilter
过滤器来拦截用户名密码认证请求,将用户名和密码封装成一个UsernamePasswordAuthenticationToken
对象交给AuthenticationManager
处理。AuthenticationManager
将抛出一个支持该类型Token的AuthenticationProvider
来进行认证,认证过程中DaoAuthenticationProvider
将调用UserDetailService
的loadUserByUsername
来获取UserDetails
对象,如果UserDetails
不为空并且密码和用户输入的密码匹配一致的话,则将认证信息保存到session中,认证后我们便可以通过Authentication
对象获取到认证的信息。
由于Spring Security并没有提供验证码认证的流程,所以我需要仿照上图左边的流程实现,如右图所示。
在这个流程中,我们自定义一个名为CodeAuthenticationFilter
的过滤器来拦截邮件验证码登录请求,并将邮箱封装到一个EmailCodeAuthenticationToken
对象中。在Spring Security中,认证处理都需要通过AuthenticationManager
来代理,所以这里我们依旧将EmailCodeAuthenticationToken
交由AuthenticationManager
处理。接着我们需要定义一个处理EmailCodeAuthenticationToken
对象的CodeAuthenticationProvider
,EmailAuthenticationProvider
调用UserDetailService
的loadUserByUsername
方法来处理认证。与用户名密码认证不一样的是,这里是通过EmailCodeAuthenticationToken
中的邮箱去数据库中查询是否有与之对应的用户,如果有,则将该用户信息封装到UserDetails
对象中返回并将认证后的信息保存到Authentication
对象中。
为了实现这个流程,我们需要实现EmailCodeAuthenticationToken
、EmailCodeAuthenticationFilter
和EmailCodeAuthenticationProvider
,并将这些组合起来添加到Spring Security中。
整个项目结构如下:
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 top └── parak ├── SpringSecurityOAuth2EmailApplication 项目启动类 ├── config │ ├── AuthorizationServerConfig 授权服务器 │ └── ResourceServerConfig 资源服务器 ├── controller │ ├── IndexController 资源接口 │ └── ValidateController 验证码接口 ├── entity │ └── MyUser 自定义用户 ├── handler │ ├── MyAuthenticationFailureHandler 处理登录成功 │ └── MyAuthenticationSuccessHandler 处理登录失败 ├── service │ ├── EmailCodeService 发送邮件服务 │ ├── EmailUserDetailService 自定义邮箱认证 │ └── RedisCodeService.java 验证码缓存操作 └── validate └── emailcode ├── EmailCode 邮件验证码 ├── EmailCodeAuthenticationConfig 邮件验证码配置 ├── EmailCodeAuthenticationFilter 登录请求过滤器 ├── EmailCodeAuthenticationProvider 用户邮箱校验 ├── EmailCodeAuthenticationToken 邮件验证码令牌 └── EmailCodeFilter 邮件验证码校验
其中自定义用户、授权服务器、登录成功和失败的处理器以及首页接口延用上次创建的项目即可。
我们先实现邮件发送功能。
定义EmailCode
封装邮件验证码:
1 2 3 4 5 @Data @AllArgsConstructor public class EmailCode { private String code; }
定义EmailUserDetailService
实现邮箱认证逻辑:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 @Configuration public class EmailUserDetailService implements UserDetailsService { @Autowired private PasswordEncoder passwordEncoder; @Override public UserDetails loadUserByUsername (String email) throws UsernameNotFoundException { MyUser user = new MyUser (); user.setUserName(email); user.setPassword(passwordEncoder.encode("KAG1823" )); List<GrantedAuthority> authorityList = new ArrayList <>(); if (StringUtils.equalsIgnoreCase("parakovo@gmail" , email)) { authorityList = AuthorityUtils.commaSeparatedStringToAuthorityList("admin" ); } else { authorityList = AuthorityUtils.commaSeparatedStringToAuthorityList("guest" ); } return new User (email, user.getPassword(), user.isEnabled(), user.isAccountNonExpired(), user.isCredentialsNonExpired(), user.isAccountNonLocked(), authorityList); } }
定义RedisCodeService
实现缓存对于验证码的操作:
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 @Service public class RedisCodeService { private final static String EMAIL_CODE_PREFIX = "EMAIL_CODE:" ; private final static Integer EMAIL_CODE_TIMEOUT = 300 ; @Autowired private StringRedisTemplate redisTemplate; public void save (EmailCode emailCode, ServletWebRequest request, String email) throws Exception { redisTemplate.opsForValue().set(key(request, email), emailCode.getCode(), EMAIL_CODE_TIMEOUT, TimeUnit.SECONDS); } public String get (ServletWebRequest request, String email) throws Exception{ String key = key(request, email); Long expire = redisTemplate.opsForValue().getOperations().getExpire(key); if (expire == -2 ) throw new Exception ("The verification code has expired" ); return redisTemplate.opsForValue().get(key); } public void remove (ServletWebRequest request, String email) throws Exception { redisTemplate.delete(key(request, email)); } private String key (ServletWebRequest request, String email) throws Exception { String deviceId = request.getHeader("deviceId" ); if (StringUtils.isBlank(deviceId)) { throw new Exception ("No device id was found in request header" ); } return EMAIL_CODE_PREFIX + deviceId + ":" + email; } }
定义EmailCodeService
实现邮件验证码的发送:
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 @Service public class EmailCodeService { @Value("${spring.mail.username}") private String fromMail; @Autowired private MailSender mailSender; @Autowired private RedisCodeService redisCodeService; public void send (ServletWebRequest request, String code, String toMail) throws Exception { SimpleMailMessage message = new SimpleMailMessage (); message.setFrom(fromMail); message.setTo(toMail); message.setSubject("SpringSecurity验证码" ); message.setText("您正在登录Spring-Security-OAuth2, 验证码为:" + code); try { mailSender.send(message); } catch (Exception e) { redisCodeService.remove(request, toMail); throw new Exception (e.getMessage()); } } }
然后我们编写REST接口,发送邮件验证码:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 @Slf4j @RestController public class ValidateController { @Autowired private RedisCodeService redisCodeService; @Autowired private EmailCodeService emailCodeService; @GetMapping("/code/email") public void sendEmailCode (HttpServletRequest request, String email) throws Exception { String code = RandomStringUtils.randomNumeric(6 ); EmailCode emailCode = new EmailCode (code); ServletWebRequest webRequest = new ServletWebRequest (request); emailCodeService.send(webRequest, code, email); redisCodeService.save(emailCode, webRequest, email); log.info("用户使用邮箱[{}]登录,验证码为:[{}]" , email, code); } }
然后实现邮件验证码的认证流程。
查看UsernamePasswordAuthenticationToken
的源码,将其复制出来重命名为EmailCodeAuthenticationToken
,并稍作修改,代码如下:
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 public class EmailCodeAuthenticationToken extends AbstractAuthenticationToken { private static final long serialVersionUID = -8522249673088578015L ; private final Object principal; public EmailCodeAuthenticationToken (String email) { super (null ); this .principal = email; setAuthenticated(false ); } public EmailCodeAuthenticationToken (Object principal, Collection<? extends GrantedAuthority> authorities) { super (authorities); this .principal = principal; super .setAuthenticated(true ); } @Override public Object getCredentials () { return null ; } @Override public Object getPrincipal () { return this .principal; } public void setAuthenticated (boolean isAuthenticated) throws IllegalArgumentException { if (isAuthenticated) { throw new IllegalArgumentException ( "Cannot set this token trusted - use constructor which takes a GrantedAuthority list instead" ); } super .setAuthenticated(false ); } @Override public void eraseCredentials () { super .eraseCredentials(); } }
EmailCodeAuthenticationToken
包含一个principal
属性,从它的两个构造函数可以看出,在认证之前principal
存的是手机号,认证之后存的是用户信息。UsernamePasswordAuthenticationToken
原来还包含一个credentials
属性用于存放密码,这里不需要就去掉了。
接着定义用于处理短信验证码登录请求的过滤器EmailCodeAuthenticationFilter
,同样的复制UsernamePasswordAuthenticationFilter
源码并稍作修改:
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 public class EmailCodeAuthenticationFilter extends AbstractAuthenticationProcessingFilter { public static final String EMAIL_KEY = "email" ; private String emailParameter = EMAIL_KEY; private boolean postOnly = true ; protected EmailCodeAuthenticationFilter () { super (new AntPathRequestMatcher ("/login/email" , "POST" )); } public void setEmailParameter (String emailParameter) { this .emailParameter = emailParameter; } public void setPostOnly (boolean postOnly) { this .postOnly = postOnly; } public final String getEmailParameter () { return emailParameter; } @Override public Authentication attemptAuthentication (HttpServletRequest request, HttpServletResponse response) throws AuthenticationException, IOException, ServletException { if (postOnly && !request.getMethod().equals("POST" )) throw new AuthenticationServiceException ("Authentication method not supported: " + request.getMethod()); String email = request.getParameter(emailParameter); if (email == null ) email = "" ; email = email.trim(); EmailCodeAuthenticationToken token = new EmailCodeAuthenticationToken (email); token.setDetails(authenticationDetailsSource.buildDetails(request)); return this .getAuthenticationManager().authenticate(token); } }
构造函数中指定了当请求路径为login.email
,请求方法为POST的时候该过滤器生效。emailParameter
属性值为email
,对应登录页面邮件输入框的name
属性,并调用EmailCodeAuthenticationToken
的构造方法EmailCodeAuthenticationToken(String email)
创建了一个EmailCodeAuthenticationToken
。下一步就如流程图中所示的那样,EmailCodeAuthenticationFilter
将EmailCodeAuthenticationToken
交给AuthenticationManager
处理。
然后我们需要创建一个支持处理EmailCodeAuthenticationToken
的类,即EmailCodeAuthenticationProvider
,该类需要实现AuthenticationProvider
的两个抽象方法,主要实现登录的邮箱存在性校验:
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 public class EmailCodeAuthenticationProvider implements AuthenticationProvider { private UserDetailsService userDetailsService; public UserDetailsService getUserDetailsService () { return userDetailsService; } public void setUserDetailsService (UserDetailsService userDetailsService) { this .userDetailsService = userDetailsService; } @Override public Authentication authenticate (Authentication authentication) throws AuthenticationException { EmailCodeAuthenticationToken authenticationToken = (EmailCodeAuthenticationToken) authentication; UserDetails userDetails = userDetailsService.loadUserByUsername((String) authenticationToken.getPrincipal()); if (userDetails == null ) throw new InternalAuthenticationServiceException ("The user corresponding to the email was not found" ); EmailCodeAuthenticationToken authenticationResult = new EmailCodeAuthenticationToken (userDetails, userDetails.getAuthorities()); authenticationResult.setDetails(authenticationToken.getDetails()); return authenticationResult; } @Override public boolean supports (Class<?> aClass) { return EmailCodeAuthenticationToken.class.isAssignableFrom(aClass); } }
其中supports
方法指定了支持处理的Token类型为EmailCodeAuthenticationToken
,authenticate
方法用于编写具体的身份认证逻辑,从EmailCodeAuthenticationToken
中取出邮箱信息,并调用UserDeatilService
方法的loadUserByUsername
方法,通过邮箱查询用户,如果存在该用户则认证成功,通过后接着调用EmailCodeAuthenticationToken
的构造方法EmailCodeAuthenticationToken(Object principal, Collection<? extends GrantedAuthority> authorities)
构造一个认证通过的Token,包含了用户信息和用户权限。
完成邮箱存在性校验之后,我们需要校验验证码的正确性,定义一个过滤器EmailCodeFilter
,继承一次性过滤器OncePerRequestFilter
:
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 @Component public class EmailCodeFilter extends OncePerRequestFilter { @Autowired private AuthenticationFailureHandler authenticationFailureHandler; @Autowired private RedisCodeService redisCodeService; @Override protected void doFilterInternal (HttpServletRequest request, HttpServletResponse response, FilterChain filterChain) throws ServletException, IOException { if (StringUtils.equalsIgnoreCase("/login/email" , request.getRequestURI()) && StringUtils.equalsIgnoreCase("post" , request.getMethod())) { try { validateCode(new ServletWebRequest (request)); } catch (Exception e) { authenticationFailureHandler.onAuthenticationFailure(request, response, new AuthenticationServiceException (e.getMessage())); return ; } } filterChain.doFilter(request, response); } private void validateCode (ServletWebRequest request) throws Exception { String codeInRequest = ServletRequestUtils.getStringParameter(request.getRequest(), "code" ); String emailInRequest = ServletRequestUtils.getRequiredStringParameter(request.getRequest(), "email" ); if (StringUtils.isBlank(codeInRequest)) throw new Exception ("The verification code cannot be null" ); String codeInRedis = redisCodeService.get(request, emailInRequest); if (codeInRedis == null ) throw new Exception ("No verification code was found for email " + emailInRequest); if (!StringUtils.equals(codeInRequest, codeInRedis)) throw new Exception ("The verification code is wrong" ); redisCodeService.remove(request, emailInRequest); } }
主要方法为doFilterInternal
,我们判断了请求路径是否为/login/email
以及请求方法是否为POST
,如果是则执行验证码校验逻辑,否则直接执行filterChain.doFilter
让代码向下执行。在校验验证码的validateCode
方法,将请求参数中的验证码与Redis中的验证码进行比对,如果捕获到异常,则调用Spring Security的校验失败处理器AuthenticationFailureHandler
进行处理。
在定义完所有的组件后,我们需要进行一些配置,将这些组件组合起来形成和上面流程图对应的流程。
创建一个配置类EmailCodeAuthenticationConfig
:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 @Component public class EmailCodeAuthenticationConfig extends SecurityConfigurerAdapter <DefaultSecurityFilterChain, HttpSecurity> { @Autowired private AuthenticationSuccessHandler authenticationSuccessHandler; @Autowired private AuthenticationFailureHandler authenticationFailureHandler; @Qualifier("emailUserDetailService") @Autowired private UserDetailsService userDetailsService; @Override public void configure (HttpSecurity httpSecurity) throws Exception { EmailCodeAuthenticationFilter emailCodeAuthenticationFilter = new EmailCodeAuthenticationFilter (); emailCodeAuthenticationFilter.setAuthenticationManager(httpSecurity.getSharedObject(AuthenticationManager.class)); emailCodeAuthenticationFilter.setAuthenticationSuccessHandler(authenticationSuccessHandler); emailCodeAuthenticationFilter.setAuthenticationFailureHandler(authenticationFailureHandler); EmailCodeAuthenticationProvider emailCodeAuthenticationProvider = new EmailCodeAuthenticationProvider (); emailCodeAuthenticationProvider.setUserDetailsService(userDetailsService); httpSecurity.authenticationProvider(emailCodeAuthenticationProvider) .addFilterAfter(emailCodeAuthenticationFilter, UsernamePasswordAuthenticationFilter.class); } }
第一步需要配置EmailCodeAuthenticationFilter
,分别设置AuthenticationManager
、AuthenticationSuccessHandler
和AuthenticationFailureHandler
这三个属性,都是来自它所继承的AbstractAuthenticationProcessingFilter
类中。第二步配置EmailCodeAuthenticationProvider
,这一步只需要把自定义的EmailUserDetailService
注入进来即可。第三步调用HttpSecurity
的authenticationProvider
方法指定AuthenticationProvider
为EmailCodeAuthenticationProvider
,并将EmailCodeAuthenticationFilter
过滤器添加到UsernamePasswordAuthenticationFilter
后面。
至此我们已经将邮件验证码的各个组件组合起来了,最会一步需要做的就是配置邮件验证码过滤器,并且将邮件验证码认证流程加入到Spring Security中,在资源服务器的configure
方法中添加如下配置:
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 @Configuration @EnableResourceServer public class ResourceServerConfig extends ResourceServerConfigurerAdapter { @Autowired private MyAuthenticationSuccessHandler authenticationSuccessHandler; @Autowired private MyAuthenticationFailureHandler authenticationFailureHandler; @Autowired private EmailCodeFilter emailCodeFilter; @Autowired private EmailCodeAuthenticationConfig emailCodeAuthenticationConfig; @Override public void configure (HttpSecurity http) throws Exception { http.addFilterBefore(emailCodeFilter, UsernamePasswordAuthenticationFilter.class) .formLogin() .loginProcessingUrl("/login" ) .successHandler(authenticationSuccessHandler) .failureHandler(authenticationFailureHandler) .and() .authorizeRequests() .antMatchers("/code/email" ).permitAll() .anyRequest().authenticated() .and() .csrf().disable() .apply(emailCodeAuthenticationConfig); } }
至此,coding结束。
启动项目,访问验证码接口:http://localhost:8080/code/email?email=parakovo@gmail.com
控制台日志打印:
1 2021 -06 -03 00 :46 :09.955 INFO 9756 --- [nio-8080 -exec-3 ] top.parak.controller.ValidateController : 用户使用邮箱[parakovo@gmail .com]登录,验证码为:[196267 ]
然后请求授权码接口:http://localhost:8080/login/email?email=parakovo@gmail.com&code=196267
最后测试访问资源:
8. Spring Security OAuth2 Config 📖概述
Spring Security允许我们自定义令牌配置,比如不同的client_id对应不同的令牌,令牌的有效时间、令牌的存储策略等;我们也可以使用JWT来替换默认的令牌。
8.1 自定义令牌配置 新建项目,引入依赖:
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 <dependency > <groupId > org.projectlombok</groupId > <artifactId > lombok</artifactId > </dependency > <dependency > <groupId > org.springframework.boot</groupId > <artifactId > spring-boot-starter-web</artifactId > </dependency > <dependency > <groupId > org.springframework.boot</groupId > <artifactId > spring-boot-starter-data-redis</artifactId > </dependency > <dependency > <groupId > org.springframework.cloud</groupId > <artifactId > spring-cloud-starter</artifactId > </dependency > <dependency > <groupId > org.springframework.cloud</groupId > <artifactId > spring-cloud-starter-oauth2</artifactId > </dependency > <dependency > <groupId > org.springframework.cloud</groupId > <artifactId > spring-cloud-starter-security</artifactId > </dependency > <dependency > <groupId > org.apache.commons</groupId > <artifactId > commons-lang3</artifactId > </dependency >
创建配置类SecurityConfig
,用于注册常用的bean:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 @Configuration public class SecurityConfig extends WebSecurityConfigurerAdapter { @Bean public PasswordEncoder passwordEncoder () { return new BCryptPasswordEncoder (); } @Bean(name = BeanIds.AUTHENTICATION_MANAGER) @Override public AuthenticationManager authenticationManagerBean () throws Exception { return super .authenticationManagerBean(); } }
定义NameUserDetailService
实现UserDetailsService
,实现自己的用户名密码认证逻辑:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 @Configuration public class NameUserDetailService implements UserDetailsService { @Autowired private PasswordEncoder passwordEncoder; @Override public UserDetails loadUserByUsername (String username) throws UsernameNotFoundException { String password = passwordEncoder.encode("KAG1823" ); List<GrantedAuthority> authorityList = new ArrayList <>(); if (StringUtils.equalsIgnoreCase("Khighness" , username)) { authorityList = AuthorityUtils.commaSeparatedStringToAuthorityList("admin" ); } else { authorityList = AuthorityUtils.commaSeparatedStringToAuthorityList("guest" ); } return new User (username, password, authorityList); } }
然后创建授权服务器AuthorizationServerConfig
,注意在新版本的spring-cloud-starter-oauth2中,指定client_secret需要进行加密处理,这里依然使用BCR加密:
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 @Configuration @EnableAuthorizationServer public class AuthorizationServerConfig extends AuthorizationServerConfigurerAdapter { @Autowired private AuthenticationManager authenticationManager; @Qualifier("nameUserDetailService") @Autowired private UserDetailsService userDetailsService; @Autowired private PasswordEncoder passwordEncoder; @Override public void configure (AuthorizationServerEndpointsConfigurer endpoints) throws Exception { endpoints.authenticationManager(authenticationManager) .userDetailsService(userDetailsService); } @Override public void configure (ClientDetailsServiceConfigurer clients) throws Exception { clients.inMemory() .withClient("k" ) .secret(passwordEncoder.encode("parak" )) .accessTokenValiditySeconds(3600 ) .refreshTokenValiditySeconds(86400 ) .scopes("all" , "a" , "b" , "c" ) .authorizedGrantTypes("password" , "refresh_token" ) .and() .withClient("z" ) .secret(passwordEncoder.encode("paraz" )) .accessTokenValiditySeconds(7200 ) ; } }
在重写的configure(AuthorizationServerEndpointsConfigurer endpoints)
方法中,指定AuthenticationManager
和UserDetailsService
。
在重写的configure(ClientDetailsServiceConfigurer clients)
方法中,主要配置:
定义两个client_id,k与z,客户端可以通过不同的client_id来获取不同的令牌;
k的令牌有效时间为3600秒(1小时),z的令牌有效时间为7200秒(2小时);
k的refresh_token的有效时间为86400秒(1天),即10天内都可以通过refresh_token换取新的令牌;
在获取k的令牌时,scope只能为all、a、b、c中的某个值,否则将获取失败;
在获取k的令牌时,只能通过密码模式(password)和刷新方式(refresh_token)来获取令牌。
注意,还需要修改登录成功的处理器,由于client_serect加密了,所有判断逻辑也需要调整:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 @Slf4j @Component public class MyAuthenticationSuccessHandler implements AuthenticationSuccessHandler { ... @Autowired private PasswordEncoder passwordEncoder; @Override public void onAuthenticationSuccess (HttpServletRequest request, HttpServletResponse response, Authentication authentication) throws IOException, ServletException { ... if (clientDetails == null ) { throw new UnapprovedClientAuthenticationException ("No client details was found for clientId " + clientId); } else if (!passwordEncoder.matches(clientSecret, clientDetails.getClientSecret())) { throw new UnapprovedClientAuthenticationException ("The client secret is invalid" ); } ... } }
最后,创建资源服务器ResourceServerConfig
,处理登录失败的逻辑不变:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 @Configuration @EnableResourceServer public class ResourceServerConfig extends ResourceServerConfigurerAdapter { @Autowired private MyAuthenticationSuccessHandler authenticationSuccessHandler; @Autowired private MyAuthenticationFailureHandler authenticationFailureHandler; @Override public void configure (HttpSecurity http) throws Exception { http.formLogin() .loginProcessingUrl("/login" ) .successHandler(authenticationSuccessHandler) .failureHandler(authenticationFailureHandler) .and() .authorizeRequests() .anyRequest().authenticated() .and() .csrf().disable() ; } }
启动项目,使用密码模式获取令牌,注意请求头中添加Basic Base54(k:parak)
:
可以看到expires_in
是我们定义的3600秒。
测试一下将scope指定为k会有什么结果:
8.2 自定义存储redis 默认令牌是存储在内存中的,我们可以将它保存到第三方存储中,比如redis。
首先创建TokenStoreConfig
:
1 2 3 4 5 6 7 8 9 10 @Configuration public class TokenStoreConfig { @Autowired private RedisConnectionFactory redisConnectionFactory; @Bean public TokenStore redisTokenStore () { return new RedisTokenStore (redisConnectionFactory); } }
然后在认证服务器中这个指定该存储策略:
1 2 3 4 5 6 7 8 9 10 @Qualifier("redisTokenStore") @Autowired private TokenStore redisTokenStore;@Override public void configure (AuthorizationServerEndpointsConfigurer endpoints) throws Exception { endpoints.authenticationManager(authenticationManager) .userDetailsService(userDetailsService) .tokenStore(redisTokenStore); }
重启项目获取令牌,查看redis中是否存储了令牌相关信息:
可以看到,令牌信息已经存储在redis中了。
8.2 自定义令牌jwt 默认令牌应该很容易看得出来,使用UUID生产,JWT替换默认令牌只需要指定TokenStore
为JwtTokenStore
即可。
创建一个JWTokenConfig
配置类:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 @Configuration public class JWTokenConfig { @Bean public TokenStore jwtTokenStore () { return new JwtTokenStore (jwtAccessTokenConverter()); } @Bean public JwtAccessTokenConverter jwtAccessTokenConverter () { JwtAccessTokenConverter accessTokenConverter = new JwtAccessTokenConverter (); accessTokenConverter.setSigningKey("parak.top" ); return accessTokenConverter; } }
在认证服务器中指定:
1 2 3 4 5 6 7 8 9 10 11 12 13 @Qualifier("jwtTokenStore") @Autowired private TokenStore jwtTokenStore;@Autowired private JwtAccessTokenConverter jwtAccessTokenConverter;@Override public void configure (AuthorizationServerEndpointsConfigurer endpoints) throws Exception { endpoints.authenticationManager(authenticationManager) .userDetailsService(userDetailsService) .tokenStore(jwtTokenStore) .accessTokenConverter(jwtAccessTokenConverter); }
最后创建一个REST接口:
1 2 3 4 5 6 7 8 @RestController public class IndexController { @GetMapping("/index") public Object index (@AuthenticationPrincipal Authentication authentication) { return authentication; } }
重启服务器,获取令牌:
1 2 3 4 5 6 7 8 { "access_token" : "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJleHAiOjE2MjI3MTQyNTcsInVzZXJfbmFtZSI6IktoaWdobmVzcyIsImF1dGhvcml0aWVzIjpbImd1ZXN0Il0sImp0aSI6IjU1MWE2OGRkLTczMmItNDMxNi04ZjE5LTNhNTQ5NTk3NmQ0NCIsImNsaWVudF9pZCI6ImsiLCJzY29wZSI6WyJhbGwiXX0.WUstg6FB2SO3dzuRgI2igpGfP4oZqLPUnXFwuc8BU8E" , "token_type" : "bearer" , "refresh_token" : "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJ1c2VyX25hbWUiOiJLaGlnaG5lc3MiLCJzY29wZSI6WyJhbGwiXSwiYXRpIjoiNTUxYTY4ZGQtNzMyYi00MzE2LThmMTktM2E1NDk1OTc2ZDQ0IiwiZXhwIjoxNjIyNzk3MDU3LCJhdXRob3JpdGllcyI6WyJndWVzdCJdLCJqdGkiOiJlMDM4ZjJjMi1hYjk2LTRmODgtYWVlMy05ZmQyNjAwNGE0ZWUiLCJjbGllbnRfaWQiOiJrIn0.3ox9jwGs8jQzwItcPDJ5gv4G6pusYLTca8GHJJUjP5w" , "expires_in" : 3599 , "scope" : "all" , "jti" : "551a68dd-732b-4316-8f19-3a5495976d44" }
将access_token
中的内容复制到jwt官网 进行解析:
然后我们可以用这个access_token
请求资源:
此外我们还可以在jwt的基础上进行拓展,比如增加一些负载(包含不敏感信息)。
我们需要定义JWTOkenEnhancer
实现TokenEnhancer
(Token增强器):
1 2 3 4 5 6 7 8 9 public class JWTokenEnhancer implements TokenEnhancer { @Override public OAuth2AccessToken enhance (OAuth2AccessToken oAuth2AccessToken, OAuth2Authentication oAuth2Authentication) { Map<String, Object> info = new HashMap <>(); info.put("message" , "Hello Khighness" ); ((DefaultOAuth2AccessToken) oAuth2AccessToken).setAdditionalInformation(info); return oAuth2AccessToken; } }
我们在Token中添加了信息message: Hello Khighness
,然后在JWTokenConfig
中注册该bean:
1 2 3 4 @Bean public TokenEnhancer tokenEnhancer () { return new JWTokenEnhancer (); }
最后在认证服务器中配置Token增强器:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 @Qualifier("jwtTokenStore") @Autowired private TokenStore jwtTokenStore;@Autowired private JwtAccessTokenConverter jwtAccessTokenConverter;@Qualifier("jwTokenEnhancer") @Autowired private TokenEnhancer tokenEnhancer;@Override public void configure (AuthorizationServerEndpointsConfigurer endpoints) throws Exception { TokenEnhancerChain tokenEnhancerChain = new TokenEnhancerChain (); List<TokenEnhancer> enhancerList = new ArrayList <>(); enhancerList.add(tokenEnhancer); enhancerList.add(jwtAccessTokenConverter); tokenEnhancerChain.setTokenEnhancers(enhancerList); endpoints.authenticationManager(authenticationManager) .userDetailsService(userDetailsService) .tokenEnhancer(tokenEnhancerChain); }
重启项目,再次获取令牌,结果如下:
1 2 3 4 5 6 7 8 9 { "access_token" : "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJ1c2VyX25hbWUiOiJLaGlnaG5lc3MiLCJzY29wZSI6WyJhbGwiXSwiZXhwIjoxNjIyNzE3OTQ2LCJtZXNzYWdlIjoiSGVsbG8gS2hpZ2huZXNzIiwiYXV0aG9yaXRpZXMiOlsiYWRtaW4iXSwianRpIjoiYzQxYzI3ODItNDg5OS00ZDNjLTlmM2YtYjQ3YzNlNjNjYzU3IiwiY2xpZW50X2lkIjoiayJ9.TCrZOLP7cUFW1EjLgmk9xl75uv48bU3EUaNCn87ts1s" , "token_type" : "bearer" , "refresh_token" : "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJ1c2VyX25hbWUiOiJLaGlnaG5lc3MiLCJzY29wZSI6WyJhbGwiXSwiYXRpIjoiYzQxYzI3ODItNDg5OS00ZDNjLTlmM2YtYjQ3YzNlNjNjYzU3IiwiZXhwIjoxNjIyODAwNzQ2LCJtZXNzYWdlIjoiSGVsbG8gS2hpZ2huZXNzIiwiYXV0aG9yaXRpZXMiOlsiYWRtaW4iXSwianRpIjoiOTU3MTc1MDgtZGRkYS00MzNhLWEzNjktMzc0MjAwYzQxNjQxIiwiY2xpZW50X2lkIjoiayJ9.2dSVNonOZRXtyc0YhQ-xk1QwYnrj16JzDBzhASiOD9E" , "expires_in" : 3599 , "scope" : "all" , "message" : "Hello Khighness" , "jti" : "c41c2782-4899-4d3c-9f3f-b47c3e63cc57" }
可以看到返回的json中包含了我们添加的message。
然后我们修改REST接口,将返回结果改为JWT解析结果:
1 2 3 4 5 6 @GetMapping("/index") public Object index (@AuthenticationPrincipal Authentication authentication, HttpServletRequest request) { String authorization = request.getHeader("Authorization" ); String token = StringUtils.substringAfter(authorization, "bearer" ); return Jwts.parser().setSigningKey("parak.top" .getBytes(StandardCharsets.UTF_8)).parseClaimsJws(token).getBody(); }
其中signkey
需要和JwtAccessTokenConverter
中指定的签名密钥一直。
重启项目,获取令牌访问/index
,结果如下:
1 2 3 4 5 6 7 8 9 10 11 12 13 { "user_name" : "Khighness" , "scope" : [ "all" ], "exp" : 1622718508 , "message" : "Hello Khighness" , "authorities" : [ "admin" ], "jti" : "a0653a55-5b63-4b8e-a95c-c9b81f3a575c" , "client_id" : "k" }
8.4 令牌刷新refresh_token refresh_token
,是四种标准的OAuth2了令牌获取方式之外,Spring Security OAuth2内部实现的一中拓展的获取令牌的方式。
假设现在access_token
过期了,我们可以用refresh_token
去换取新的令牌:
使用postman发送请求,需要两个参数,令牌获取方式grant_type
指定为refresh_token
,更新令牌refresh_token
设置为上一次服务器返回的refresh_token
。另外请求头依然带上Basic BASE64(client_id:client_secret)
:
9. Spring Security OAuth2 SSO 📖概述
SSO(Single Sign On),即单点登录,效果是多个系统间,只要登录了其中一个系统,其他系统也自动登录。
9.1 框架搭建 我们需要创建一个maven多模块项目,包含认证服务器和两个客户端。
1 2 3 4 sso ├── sso-server 认证服务器 ├── sso-app-one 客户端1 └── sso-app-two 客户端2
创建一个项目,引入依赖:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 <dependencies > <dependency > <groupId > org.springframework.boot</groupId > <artifactId > spring-boot-starter-web</artifactId > </dependency > <dependency > <groupId > org.springframework.cloud</groupId > <artifactId > spring-cloud-starter</artifactId > </dependency > <dependency > <groupId > org.springframework.cloud</groupId > <artifactId > spring-cloud-starter-oauth2</artifactId > </dependency > <dependency > <groupId > org.springframework.cloud</groupId > <artifactId > spring-cloud-starter-security</artifactId > </dependency > <dependency > <groupId > org.apache.commons</groupId > <artifactId > commons-lang3</artifactId > </dependency > </dependencies >
然后创建三个module:
sso-server
(8080)
sso-app-one
(8081)
sso-app-two
(8082)
9.2 认证服务器 认证服务器的作用就是作为统一令牌发放并校验。
配置如下:
1 2 3 4 server: port: 8080 servlet: context-path: /server
新建一个Spring Security配置类WebSecurityConfig
,继承WebSecurityConfigurerAdpter
:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 @Configuration public class WebSecurityConfig extends WebSecurityConfigurerAdapter { @Bean public PasswordEncoder passwordEncoder () { return new BCryptPasswordEncoder (); } @Override protected void configure (HttpSecurity http) throws Exception { http.formLogin() .and() .authorizeRequests().anyRequest().authenticated(); } }
接着定义UserDetailService
实现自定义用户登录逻辑:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 @Configuration public class UserDetailService implements UserDetailsService { @Autowired private PasswordEncoder passwordEncoder; @Override public UserDetails loadUserByUsername (String username) throws UsernameNotFoundException { String password = passwordEncoder.encode("KAG1823" ); List<GrantedAuthority> authorityList = new ArrayList <>(); if (StringUtils.equalsIgnoreCase("Khighness" , username)) { authorityList = AuthorityUtils.commaSeparatedStringToAuthorityList("admin" ); } else { authorityList = AuthorityUtils.commaSeparatedStringToAuthorityList("guest" ); } return new User (username, password, authorityList); } }
最后创建SsoAuthorizationServerConfig
配置认证服务:
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 46 47 48 49 50 51 52 53 @Configuration @EnableAuthorizationServer public class SsoAuthorizationServerConfig extends AuthorizationServerConfigurerAdapter { @Autowired private PasswordEncoder passwordEncoder; @Autowired private UserDetailService userDetailService; @Bean public TokenStore jwtTokenStore () { return new JwtTokenStore (jwtAccessTokenConverter()); } @Bean public JwtAccessTokenConverter jwtAccessTokenConverter () { JwtAccessTokenConverter accessTokenConverter = new JwtAccessTokenConverter (); accessTokenConverter.setSigningKey("sso.parak.top" ); return accessTokenConverter; } @Override public void configure (ClientDetailsServiceConfigurer clients) throws Exception { clients.inMemory() .withClient("app-one" ) .secret(passwordEncoder.encode("app-one-parak" )) .authorizedGrantTypes("authorization_code" , "refresh_token" ) .accessTokenValiditySeconds(3600 ) .refreshTokenValiditySeconds(864000 ) .scopes("all" , "a" , "b" , "c" ) .redirectUris("http://127.0.0.1:8081/app-one/login" ) .and() .withClient("app-two" ) .secret(passwordEncoder.encode("app-two-parak" )) .authorizedGrantTypes("authorization_code" , "refresh_token" ) .accessTokenValiditySeconds(3600 ) .refreshTokenValiditySeconds(864000 ) .scopes("all" , "d" , "e" , "f" ) .redirectUris("http://127.0.0.1:8082/app-two/login" ) ; } @Override public void configure (AuthorizationServerEndpointsConfigurer endpoints) throws Exception { endpoints.userDetailsService(userDetailService) .tokenStore(jwtTokenStore()) .accessTokenConverter(jwtAccessTokenConverter()); } @Override public void configure (AuthorizationServerSecurityConfigurer security) throws Exception { security.tokenKeyAccess("isAuthenticated()" ); } }
配置了两个客户端,都可以使用授权码模式或者令牌更新获取令牌。
9.3 客户端配置 两个客户端的代码一致,所以只介绍一个,sso-app-one
。
客户端的SpringBoot启动类上添加@EnableOAuth2Sso
注解,开启SSO的支持:
1 2 3 4 5 6 7 @EnableOAuth2Sso @SpringBootApplication public class SsoOneApplication { public static void main (String[] args) { SpringApplication.run(SsoOneApplication.class, args); } }
重点在于配置,两个客户端的配置如下:
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 server: port: 8081 servlet: context-path: /app-one security: oauth2: client: client-id: app-one client-secret: app-one-parak user-authorization-uri: http://127.0.0.1:8080/server/oauth/authorize access-token-uri: http://127.0.0.1:8080/server/oauth/token resource: jwt: key-uri: http://127.0.0.1:8080/server/oauth/token_key sso-app-two server: port: 8082 servlet: context-path: /app-two security: oauth2: client: client-id: app-two client-secret: app-two-parak user-authorization-uri: http://127.0.0.1:8080/server/oauth/authorize access-token-uri: http://127.0.0.1:8080/server/oauth/token resource: jwt: key-uri: http://127.0.0.1:8080/server/oauth/token_key
security.oauth2.client.client-id
和security.oauth2.client.client-secret
指定了客户端id和密码,user-authorization-uri
指定了认证服务器的/oauth/authorize
授权地址,access-token-uri
指定了认证服务器的/oauth/token
令牌发放地址,jwt.key-uri
指定了认证服务器的/oauth/token_key
地址。
接着在resources/static
下新增一个index.html页面,用于跳转到另一个客户端,下面是sso-app-one
的页面:
1 2 3 4 5 6 7 8 9 10 11 <!DOCTYPE html > <html lang ="en" > <head > <meta charset ="UTF-8" > <title > app-1</title > </head > <body > <h1 > app-1</h1 > <a href ="http://127.0.0.1:8082/app-two/index.html" > go to app-2</a > </body > </html >
最后写一个REST接口UserController
:
1 2 3 4 5 6 7 8 @RestController public class UserController { @GetMapping("/principal") public Principal principal (Principal principal) { return principal; } }
9.4 测试效果 首先启动sso-server
,然后启动sso-app-one
,最后启动sso-app-two
。
访问:http://127.0.0.1:8081/app-one/index.html
跳转到如下界面,输入任意用户名和密码KAG1823:
点击Sign in/Enter:
点击Authorize:
这时候app-one登录成功,点击go to app-2:
点击Authorize:
可以看到app-two也登录成功。
测试一下资源,访问:http://127.0.0.1:8082/app-two/principle
成功获取到用户信息。
在这个过程中,每次都需要用户手动点击Authorize授权,体验不是很好。修改认证服务器client配置:
1 2 3 4 5 clients.inMemory() .withClient("app-one" ) ... .autoApprove(true ) ...
autoApprove(true)
实现自动授权。
9.5 权限校验 在单点登录模式下进行权限校验,只需要修改客户端。
在客户端新增Spring Security配置类:
1 2 3 4 @Configuration @EnableGlobalMethodSecurity(prePostEnabled = true) public class WebSecurityConfig {}
在客户端的REST接口中添加接口用于测试:
1 2 3 4 5 6 7 8 9 10 11 @GetMapping("/write") @PreAuthorize("hasAuthority('admin')") public String write () { return "您拥有write权限!" ; } @GetMapping("/read") @PreAuthorize("hasAuthority('guest')") public String read () { return "您拥有read权限!" ; }
管理员只拥有写权限,访客只有读权限。
重启项目,使用Khighness(admin)登录app-one
测试访问写接口:http://127.0.0.1:8081/app-one/write
测试访问读接口:http://127.0.0.1:8081/app-one/read
返回403,无权限,说明注解生效。