🚀Github传送门:https://github.com/Khighness/spring-security

1. Start With Maven

<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>
        <!-- Lombok -->
        <dependency>
            <groupId>org.projectlombok</groupId>
            <artifactId>lombok</artifactId>
            <version>${lombok.version}</version>
        </dependency>
        <!-- MySQL -->
        <dependency>
            <groupId>mysql</groupId>
            <artifactId>mysql-connector-java</artifactId>
            <version>${mysql.version}</version>
        </dependency>
        <!-- JWT -->
        <dependency>
            <groupId>io.jsonwebtoken</groupId>
            <artifactId>jjwt</artifactId>
            <version>${jwt.version}</version>
        </dependency>
        <!-- Druid-->
        <dependency>
            <groupId>com.alibaba</groupId>
            <artifactId>druid</artifactId>
            <version>${druid.version}</version>
        </dependency>
        <!-- Druid Starter -->
        <dependency>
            <groupId>com.alibaba</groupId>
            <artifactId>druid-spring-boot-starter</artifactId>
            <version>${druid.version}</version>
        </dependency>
        <!-- Mybatis Starter -->
        <dependency>
            <groupId>org.mybatis.spring.boot</groupId>
            <artifactId>mybatis-spring-boot-starter</artifactId>
            <version>${mybatis.spring.boot.starter.version}</version>
        </dependency>
        <!-- Mybatis-Plus Starter -->
        <dependency>
            <groupId>com.baomidou</groupId>
            <artifactId>mybatis-plus-boot-starter</artifactId>
            <version>${mybatis-plus.boot.starter.version}</version>
        </dependency>
        <!-- SpringBoot-->
        <dependency>
            <groupId>org.springframework.boot</groupId>
            <artifactId>spring-boot-dependencies</artifactId>
            <version>${spring-boot.version}</version>
            <type>pom</type>
            <scope>import</scope>
        </dependency>
        <!-- SpringCloud -->
        <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 快速开始

创建项目,导入依赖:

<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接口:

@RestController
public class HelloController {
    @GetMapping("/{name}")
    public String sayHello(@PathVariable(value = "name") String name) {
        return "Hello " + name + "!";
    }
}

启动项目,访问http://localhost:8080/KHighness

默认用户名为user,默认密码会在IDE的控制台打印:

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应用安全配置的适配器:

@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捕获并处理,所以我们在ExceptionTranslationFilterdoFilter方法catch代码块第一行打个断点:

等会模拟未登录直接访问/{name},所以应该抛出用户未登录的异常,所以接下来应该跳转到UsernamePasswordAuthenticationFilter处理表单方式的用户认证,在UsernamePasswordAuthenticationFilterattemptAuthentication方法上打个断点:

准备完毕,访问http://localhost:8080/KHighness。

代码第一步跳转到FilterSecurityInterceptorbeforeInvocation断点上:

往下执行,因为当前请求没有经过身份认证,所以抛出异常并被ExceptionTranslationFilter捕获:

捕获异常后重定向到登录表单页面,当我们在表单登陆页面输入信息并点击Sign in后:

代码跳转到UsernamePasswordAuthenticationFilterattemptAuthentication断点上:

判断用户名和密码是否正确之后,代码又跳回到FilterSecurityInterceptorbeforeInvocation断点上:

当认证通过时,FilterSecurityInterceptor代码往下执行invoke,然后代码最终跳转到上HelloController上:

最后浏览器将显示Hello KHighness!信息。

3. Spring Security Authentication

📖概述

Spring Security支持自定义认证过程,如处理用户信息获取逻辑,使用我们自定义的登录页面替换Spring Security默认的登录页及自定义登录成功或失败后的处理逻辑等。

3.1 替换默认登录页

创建项目,导入依赖:

<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>

项目基本配置:

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

<!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

@Configuration
public class BrowserSecurityConfig extends WebSecurityConfigurerAdapter {
    @Override
    protected void configure(HttpSecurity httpSecurity) throws Exception {
        httpSecurity
            // 认证配置
            .formLogin()                                  // 设置表单登录
            .loginPage("/authentication")                 // 设置登录页面
            .loginProcessingUrl("/login")                 // 处理表单登录
            .usernameParameter("username")                // 用户名输入框的name
            .passwordParameter("password")                // 密码输入框的name
            .and()
             // 授权配置
            .authorizeRequests()
            .antMatchers("/authentication").permitAll() // 不需要认证即可访问
            .anyRequest().authenticated()               // 所有请求都需要认证
            .and()
            // 安全配置
            .csrf().disable();                           // 禁止跨站CSRF攻击
    }
}

创建一个Rest接口:

@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接口,该接口只有一个抽象方法,源码如下:

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

loadUserByUsername方法返回一个UserDetails对象,该对象也是一个接口,包含一些用于描述用户信息的方法,用于与用户输入的用户名和密码进行对比认证,源码如下:

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
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

@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

@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

@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-plusBaseMapper

然后在BrowserSecurityConfig中添加Bean,以实现BCR加密:

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

重点来了,自定义认证,创建CustomUserDetailService实现UserDetailsService接口:

@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);
        // 查询角色ID
        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方法即可。

登录成功返回认证信息:

@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接口:

@ResponseBody
@GetMapping("/success")
public Object success(Authentication authentication) {
    return authentication;
}

(2)自定义登录失败逻辑

登录失败返回错误信息:

@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

@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")                // 用户名输入框的name
            .passwordParameter("password")                // 密码输入框的name
            // 结果处理
            .successHandler(authenticationSucessHandler)  // 登录成功处理
            .failureHandler(authenticationFailureHandler) // 登录失败处理
            .and()
             // 授权配置
            .authorizeRequests()
            .antMatchers("/authentication").permitAll()   // 不需要认证即可访问
            .anyRequest().authenticated()                 // 所有请求都需要认证
            .and()
            // 关闭CSRF保护
            .csrf().disable();
    }
}

启动项目,测试访问:http://localhost:8080/hello

登录成功后返回如下信息:

// 20201222153225
// http://localhost:8080/success
​
{
  "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"
}

passwordcredentials这些敏感信息,Spring Security已经将其屏蔽。

4. Spring Security RememberMe

💠说明

这一节的代码在上一节的基础上修改。

📖概述

SpringSecurity记住我功能的实现过程:用户勾选了记住我选项并登录成功后,SpringSecurity会生成一个token,并持久化到数据库,并且生成一个与该token相对应的cookie返回给浏览器。当用户过段时间再次访问系统时,如果该cookie没有过期,SpringSecurity便会根据cookie包含的信息从数据库获取相应的token信息,然后帮用户自动完成登录操作。

4.1 登录修改

在登录页面中添加记住我复选框:

<p><input type="checkbox" name="remember-me"/>remember me</p>

注意,name必须为remember-me

4.2 数据库表

在数据库中新建表,存储token:

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持久化对象:

@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方法中开启:

@Override
protected void configure(HttpSecurity httpSecurity) throws Exception {
    httpSecurity
        ...
        .and()
        // 记住我设置
        .rememberMe()
        .tokenValiditySeconds(3600)                    // 过期时间,单位秒
        .tokenRepository(persistentTokenRepository())  // token持久化仓库
        .userDetailsService(userDetailsService)        // 处理自动登录逻辑
        ;
}

4.4 重新测试

重启项目,登录页面如下:

比较难看,无伤大雅。勾选并登录,可以看到网页多了remember-me的cookie:

查看数据库表peristent_logins

可以看到token信息已经成功持久化了。在cookie失效之前,无论是重开浏览器还是重启项目,用户都无需再次登录就可以访问。

5. Spring Security Authorization

💠说明

这一节的code依然承接上一节。

5.1 安全注解

配合授权注解使用,Spring Security提供了三种不同的安全注解:

  1. Spring Security自带的@Secured
  2. JSR-250的@RolesAllowed
  3. 表达式驱动注解:@PreAuthrize@PostAuthorize@PreFilter@PostFilter

5.2 相关配置

要开启这些注解,只需要在Spring Security配置文件中添加如下注解:

@Configuration
@EnableGlobalMethodSecurity(prePostEnabled = true)
public class BrowserSecurityConfig extends WebSecurityConfigurerAdapter {
    ...
}

自定义一个处理器,处理权限不足的情况:

@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());
    }
}

并将这个处理器添加到配置链中:

@Autowired
private MyAuthenticationAccessDeniedHandler authenticationAccessDeniedHandler;

@Override
protected void configure(HttpSecurity httpSecurity) throws Exception {
    httpSecurity
        ...
        .and()
        // 权限配置
        .exceptionHandling()
        .accessDeniedHandler(authenticationAccessDeniedHandler) // 权限不足处理
        ;
}

5.3 权限测试

编写两个接口,一个接口只允许admin和manager访问,另一个接口只允许normal访问。

@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账号登录虎牙直播,在这个过程中可以提取出以下几个名词:

  1. Third-party application 第三方应用程序 => 虎牙直播
  2. HTTP service HTTP服务提供商 => QQ
  3. Resource Owner 资源所有者 => 用户,我
  4. User Agent 用户代理 => 浏览器
  5. Authorization server 认证服务器 => QQ提供的第三方登录服务
  6. Resource server 资源服务器 => 虎牙直播提供的服务,高清直播、弹幕发送

认证服务器和资源服务器可以在同一台服务器上,比如前后端分离的服务后台,它提供认证服务(提供令牌),客户端通过令牌来从资源服务器获取服务;它们也可以不在一台服务器上,比如第三方登录的案例。

6.2 运行流程

(A)用户打开客户端以后,客户端要求用户给予授权

(B)用户同意给予客户端授权

(C)客户端使用上一步获得的授权,向认证服务器申请令牌

(D)认证服务器对客户端进行认证后,确认无误,统一发放令牌

(E)客户端使用令牌,向资源服务器申请获取资源

(F)资源服务器确认令牌无误,同意向客户端开放资源

6.3 四种模式

(1)授权码模式

授权码模式是功能最完整、流程最严密的授权模式。

它的特点是通过客户端的后台服务器,与服务提供商的认证服务器进行互动。

流程如下:

(A)客户端将用户导向认证服务器

(B)用户决定是否给客户端授权

(C)同意授权后,认证服务器将用户导向客户端提供的URL,并附上授权码

(D)客户端通过重定向URL和授权码到认证服务器换取令牌

(E)校验无误后发放令牌

在A步骤中,客户端申请认证的URI,包含以下参数:

  1. response_type:表示授权类型,必选项,此处的值固定为code,标识授权码模式
  2. client_id:表示客户端ID,必选项
  3. redirect_uri:表示重定向URI,可选项
  4. scope:表示申请的权限范围,可选项
  5. state:表示客户端的当前状态,可以指定任意值,认证服务器会原封不动地返回这个值

在C步骤中,服务器回应客户端的URI,包含以下参数:

  1. code:表示授权码,必选项。该码的有效期应该很短,通常设为10分钟,客户端只能使用该码一次,否则会被授权服务器拒绝。该码与客户端ID和重定向URI,是一一对应关

  2. state:如果客户端的请求中包含这个参数,认证服务器的回应也必须一模一样包含这个参数。

在D步骤中,客户端向认证服务器申请令牌的HTTP请求,包含以下参数:

  1. grant_type:表示使用的授权模式,必选项,此处的值固定为authorization_code
  2. code:表示上一步获得的授权码,必选项
  3. redirect_uri:表示重定向URI,必选项,且必须与A步骤中的该参数值保持一致
  4. client_id:表示客户端ID,必选项

在E步骤中,认证服务器发送的HTTP回复,包含以下参数:

  1. access_token:表示访问令牌,必选项。

  2. token_type:表示令牌类型,该值大小写不敏感,必选项,可以是bearer类型或mac类型。

  3. expires_in:表示过期时间,单位为秒。如果省略该参数,必须其他方式设置过期时间。

  4. refresh_token:表示更新令牌,用来获取下一次的访问令牌,可选项。

  5. scope:表示权限范围,如果与客户端申请的范围一致,此项可省略。

(2)简化模式

简化模式不通过第三方应用程序的服务器,直接在浏览器中向认证服务器申请令牌,跳过了“授权码”这个步骤。

它的特点是所有步骤在浏览器中完成,令牌对访问者是可见的,且客户端不需要认证。

流程如下:

(A)客户端将用户导向认证服务器。

(B)用户决定是否给于客户端授权。

(C)假设用户给予授权,认证服务器将用户导向客户端指定的”重定向URI”,并在URI的Hash部分包含了访问令牌。

(D)浏览器向资源服务器发出请求,其中不包括上一步收到的Hash值。

(E)资源服务器返回一个网页,其中包含的代码可以获取Hash值中的令牌。

(F)浏览器执行上一步获得的脚本,提取出令牌。

(G)浏览器将令牌发给客户端。

在A步骤中,客户端发出的HTTP请求,包含以下参数:

  1. response_type:表示授权类型,此处的值固定为token,必选项

  2. client_id:表示客户端的ID,必选项

  3. redirect_uri:表示重定向的URI,可选项

  4. scope:表示权限范围,可选项

  5. state:表示客户端的当前状态,可以指定任意值,认证服务器会原封不动地返回这个值

在C步骤中,认证服务器回应客户端的URI,包含以下参数:

  1. access_token:表示访问令牌,必选项

  2. token_type:表示令牌类型,该值大小写不敏感,必选项

  3. expires_in:表示过期时间,单位为秒。如果省略该参数,必须其他方式设置过期时间

  4. scope:表示权限范围,如果与客户端申请的范围一致,此项可省略

  5. state:如果客户端的请求中包含这个参数,认证服务器的回应也必须一模一样包含这个参数

(3)密码模式

密码模式中,用户向客户端提供自己的用户名和密码。客户端使用这些信息,向认证服务器索要授权。

在这种模式下,用户必须把自己的密码给客户端,但是客户端不得存储密码。这通常用在用户对客户端高度信任的情况下,比如客户端是操作系统的一部分,或者由一个著名公司出品。而认证服务器只有在其他授权模式无法执行的情况下,才能考虑使用这种模式。

流程如下:

(A)用户向客户端提供用户名和密码

(B)客户端将用户名和密码发给认证服务器,向后者请求令牌

(C)认证服务器确认无误后,向客户端提供访问令牌

在B步骤中,客户端发出的HTTP请求,包含以下参数:

  1. grant_type:表示授权类型,此处的值固定为”password”,必选项

  2. username:表示用户名,必选项

  3. password:表示用户的密码,必选项

  4. scope:表示权限范围,可选项

(4)客户端模式

客户端模式指客户端以自己的名义,而不是以用户的名义,向服务提供商进行认证。

严格地说,客户端模式并不属于OAuth框架所要解决的问题。在这种模式中,用户直接向客户端注册,客户端以自己的名义要求”服务提供商”提供服务,其实不存在授权问题。

流程如下:

(A)客户端向认证服务器进行身份认证,并要求一个访问令牌

(B)认证服务器确认无误后,向客户端提供访问令牌

在A步骤中,客户端发出的HTTP请求,包含以下参数:

  1. grant_type:表示授权类型,此处的值固定为clientcredentials,必选项

  2. scope:表示权限范围,可选项

6.4 Spring Security OAuth2

Spring框架对OAuth2协议进行了实现,下面学习授权码模式和密码模式在Spring Security OAuth2相关框架的使用。

Spring Security OAuth2主要包含认证服务器和资源服务器这两大块的实现:

认证服务器主要包含了四种授权模式的实现和Token的生成与存储,我们也可以在认证服务器中自定义获取Token的方式;资源服务器主要是在Spring Security的过滤链上加了OAuth2AuthenticationProcessFilter过滤器,即使用OAuth2协议发放令牌认证的方式来保证我们的资源。

创建项目,引入依赖:

<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对象:

@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接口:

@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,代码如下所示:

@Configuration
@EnableAuthorizationServer
public class AuthorizationServerConfig extends WebSecurityConfigurerAdapter {

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

}

启动项目,会发现控制台打印了随机分配的client-idclient-secret

security.oauth2.client.client-id = 0b560e22-4148-4bed-8c0d-e0f94813692b
security.oauth2.client.client-secret = 468daba6-e1e5-4c94-8a19-f092a81d7af5

为了方便测试,我们可以手动指定这两个值。

在配置文件application.yml中添加如下配置:

security:
  oauth2:
    client:
      client-id: k
      client-secret: parak

重启项目,发现控制台输出:

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添加配置:

security:
  oauth2:
    client:
      client-id: k
      client-secret: parak
      registered-redirect-uri: http://parak.top

重启项目,重新执行以上步骤,登录成功后跳转到授权页面:

选择同意Approve,然后点击Authorize授权按钮,页面跳转到了我们指定的redirect_uri,并且带上了授权码信息:

可以看到授权码为:Ftlxyf,然后我们就可以使用这个授权码从认证服务器获取令牌Token了。

使用postman发送请求:http://localhost:8080/oauth/token

其中五个参数即为申请令牌的参数,grant_type为授权类型,此处固定为authorization_codecode为上一步获得的授权码,client_id为客户端id,redirect_uri为重定向URI,scope为申请权限范围。

此外,我们还需要在请求头中填写如下内容:

其中,key为Authorization,value为Basic Base64(client_id:client-secret)

Base64工具:http://tool.chinaz.com/Tools/Base64.aspx

参数填写无误后,发送请求边获取到令牌:

{
    "access_token": "ef19f338-651b-43ae-9cea-66ec6e208d0c",
    "token_type": "bearer",
    "refresh_token": "6761af90-c79e-4023-b23a-3a9c4210e1d4",
    "expires_in": 43199,
    "scope": "all"
}

一个授权码只能获取一次令牌,如果再次获取,将返回:

{
    "error": "invalid_grant",
    "error_description": "Invalid authorization code: Ftlxyf"
}

为什么要配置资源服务器呢?先测试一下在没有定义资源服务器的时候,我们使用Token去获取资源会发生什么。

定义一个Rest接口:

@RestController
public class IndexController {

    @GetMapping("/index")
    public Object index(Authentication authentication) {
        return authentication;
    }
}

启动项目,使用密码模式获取令牌,参数在上述四大模式中已经提到:

然后使用该令牌访问/index,value为token_type access_token,结果如下:

虽然令牌是正确的,但是并无法访问/index,所以我们必须配置资源服务器,让客户端可以通过合法的令牌来获取资源。

资源服务器的配置也很简单,只需要在配置类上使用@EnableResourceServer注解即可:

@Configuration
@EnableResourceServer
public class ResourceServerConfig {
}

重启服务,重复上面步骤,再次访问/index,结果如下:

{
    "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,同时加入处理登录成功和失败的逻辑:

@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") // 处理表单登录URL
            .successHandler(authenticationSuccessHandler) // 处理登录成功
            .failureHandler(authenticationFailureHandler) // 处理登录失败
        .and()
            .authorizeRequests() // 授权配置
            .anyRequest()        // 所有请求
            .authenticated()     // 都需认证
        .and()
            .csrf().disable()    // 禁用CSRF保护
        ;
    }
}

处理登录失败的逻辑很简单,直接返回错误提示:

@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里生成令牌并返回:

@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 {
        // 1. 从请求头获取clientId
        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;
        // 2. 通过ClientDetailService获取ClientDetails
        ClientDetails clientDetails = clientDetailsService.loadClientByClientId(clientId);
        // 3. 校验ClientId和ClientSecret的正确性
        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 {
            // 4. 通过TokenRequest构造器生成TokenRequest
            tokenRequest = new TokenRequest(new HashMap<>(), clientId, clientDetails.getScope(), "custom");
        }
        // 5. 通过TokenRequest的createOAuth2Request方法获取OAuth2Request
        OAuth2Request auth2Request = tokenRequest.createOAuth2Request(clientDetails);
        // 6. 通过Authentication和OAuth2Request构造出OAuth2Authentication
        OAuth2Authentication auth2Authentication = new OAuth2Authentication(auth2Request, authentication);
        // 7. 通过AuthenticationSServerTokenService生成OAuth2AccessToken
        OAuth2AccessToken token = authorizationServerTokenServices.createAccessToken(auth2Authentication);
        // 8. 返回token
        log.info("[{}]登录成功", authentication.getName());
        response.setContentType("application/json;charset=utf-8");
        response.getWriter().write(objectMapper.writeValueAsString(token));
    }

    /**
     * 从header中获取client-id和client-secret
     */
    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 邮件验证码

创建项目,引入依赖:

<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:

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将调用UserDetailServiceloadUserByUsername来获取UserDetails对象,如果UserDetails不为空并且密码和用户输入的密码匹配一致的话,则将认证信息保存到session中,认证后我们便可以通过Authentication对象获取到认证的信息。

由于Spring Security并没有提供验证码认证的流程,所以我需要仿照上图左边的流程实现,如右图所示。

在这个流程中,我们自定义一个名为CodeAuthenticationFilter的过滤器来拦截邮件验证码登录请求,并将邮箱封装到一个EmailCodeAuthenticationToken对象中。在Spring Security中,认证处理都需要通过AuthenticationManager来代理,所以这里我们依旧将EmailCodeAuthenticationToken交由AuthenticationManager处理。接着我们需要定义一个处理EmailCodeAuthenticationToken对象的CodeAuthenticationProviderEmailAuthenticationProvider调用UserDetailServiceloadUserByUsername方法来处理认证。与用户名密码认证不一样的是,这里是通过EmailCodeAuthenticationToken中的邮箱去数据库中查询是否有与之对应的用户,如果有,则将该用户信息封装到UserDetails对象中返回并将认证后的信息保存到Authentication对象中。

为了实现这个流程,我们需要实现EmailCodeAuthenticationTokenEmailCodeAuthenticationFilterEmailCodeAuthenticationProvider,并将这些组合起来添加到Spring Security中。

整个项目结构如下:

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封装邮件验证码:

@Data
@AllArgsConstructor
public class EmailCode {
    private String code;
}

定义EmailUserDetailService实现邮箱认证逻辑:

@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<>();
        // 只有邮箱为parakovo@gmail的用户才为管理员admin,其他用户都为访客guest
        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实现缓存对于验证码的操作:

@Service
public class RedisCodeService {
    /** 邮箱验证码key的前缀 */
    private final static String EMAIL_CODE_PREFIX = "EMAIL_CODE:";
    /** 邮箱验证码过期时间S */
    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实现邮件验证码的发送:

@Service
public class EmailCodeService {
    @Value("${spring.mail.username}")
    private String fromMail;

    @Autowired
    private MailSender mailSender;

    @Autowired
    private RedisCodeService redisCodeService;

    /**
     * 发送验证码到指定邮箱
     * @param request 请求
     * @param code    验证码
     * @param toMail  目标邮箱
     */
    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接口,发送邮件验证码:

@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,并稍作修改,代码如下:

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源码并稍作修改:

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。下一步就如流程图中所示的那样,EmailCodeAuthenticationFilterEmailCodeAuthenticationToken交给AuthenticationManager处理。

然后我们需要创建一个支持处理EmailCodeAuthenticationToken的类,即EmailCodeAuthenticationProvider,该类需要实现AuthenticationProvider的两个抽象方法,主要实现登录的邮箱存在性校验:

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类型为EmailCodeAuthenticationTokenauthenticate方法用于编写具体的身份认证逻辑,从EmailCodeAuthenticationToken中取出邮箱信息,并调用UserDeatilService方法的loadUserByUsername方法,通过邮箱查询用户,如果存在该用户则认证成功,通过后接着调用EmailCodeAuthenticationToken的构造方法EmailCodeAuthenticationToken(Object principal, Collection<? extends GrantedAuthority> authorities)构造一个认证通过的Token,包含了用户信息和用户权限。

完成邮箱存在性校验之后,我们需要校验验证码的正确性,定义一个过滤器EmailCodeFilter,继承一次性过滤器OncePerRequestFilter

@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

@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,分别设置AuthenticationManagerAuthenticationSuccessHandlerAuthenticationFailureHandler这三个属性,都是来自它所继承的AbstractAuthenticationProcessingFilter类中。第二步配置EmailCodeAuthenticationProvider,这一步只需要把自定义的EmailUserDetailService注入进来即可。第三步调用HttpSecurityauthenticationProvider方法指定AuthenticationProviderEmailCodeAuthenticationProvider,并将EmailCodeAuthenticationFilter过滤器添加到UsernamePasswordAuthenticationFilter后面。

至此我们已经将邮件验证码的各个组件组合起来了,最会一步需要做的就是配置邮件验证码过滤器,并且将邮件验证码认证流程加入到Spring Security中,在资源服务器的configure方法中添加如下配置:

@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") // 处理表单登录URL
                .successHandler(authenticationSuccessHandler) // 处理登录成功
                .failureHandler(authenticationFailureHandler) // 处理登录失败
        .and()
                .authorizeRequests()                    // 授权配置
                .antMatchers("/code/email").permitAll() // 验证码接口无需认证
                .anyRequest().authenticated()           // 所有接口都需要认证
        .and()
                .csrf().disable() // 关闭CSRF保护
        .apply(emailCodeAuthenticationConfig);
    }
}

至此,coding结束。

启动项目,访问验证码接口:http://localhost:8080/code/email?email=parakovo@gmail.com

控制台日志打印:

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 自定义令牌配置

新建项目,引入依赖:

<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:

@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,实现自己的用户名密码认证逻辑:

@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<>();
        // Khighness为管理员,其他都为访客
        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加密:

@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)   // 1h
                .refreshTokenValiditySeconds(86400) // 1d
                .scopes("all", "a", "b", "c")
                .authorizedGrantTypes("password", "refresh_token")
            .and()
                .withClient("z")
                .secret(passwordEncoder.encode("paraz"))
                .accessTokenValiditySeconds(7200)   // 2h
        ;
    }
}

在重写的configure(AuthorizationServerEndpointsConfigurer endpoints)方法中,指定AuthenticationManagerUserDetailsService

在重写的configure(ClientDetailsServiceConfigurer clients)方法中,主要配置:

  1. 定义两个client_id,k与z,客户端可以通过不同的client_id来获取不同的令牌;
  2. k的令牌有效时间为3600秒(1小时),z的令牌有效时间为7200秒(2小时);
  3. k的refresh_token的有效时间为86400秒(1天),即10天内都可以通过refresh_token换取新的令牌;
  4. 在获取k的令牌时,scope只能为all、a、b、c中的某个值,否则将获取失败;
  5. 在获取k的令牌时,只能通过密码模式(password)和刷新方式(refresh_token)来获取令牌。

注意,还需要修改登录成功的处理器,由于client_serect加密了,所有判断逻辑也需要调整:

@Slf4j
@Component
public class MyAuthenticationSuccessHandler implements AuthenticationSuccessHandler {
    ...
    @Autowired
    private PasswordEncoder passwordEncoder;

    @Override
    public void onAuthenticationSuccess(HttpServletRequest request, HttpServletResponse response, Authentication authentication) throws IOException, ServletException {
        ...
        // 3. 校验ClientId和ClientSecret的正确性
        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,处理登录失败的逻辑不变:

@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") // 处理表单登录URL
                .successHandler(authenticationSuccessHandler) // 处理登录成功
                .failureHandler(authenticationFailureHandler) // 处理登录失败
        .and()
                .authorizeRequests()                                // 授权配置
                .anyRequest().authenticated()                       // 所有接口都需要认证
        .and()
                .csrf().disable() // 关闭CSRF保护
        ;
    }
}

启动项目,使用密码模式获取令牌,注意请求头中添加Basic Base54(k:parak)

可以看到expires_in是我们定义的3600妙。

测试一下将scope指定为k会有什么结果:

8.2 自定义存储redis

默认令牌是存储在内存中的,我们可以将它保存到第三方存储中,比如redis。

首先创建TokenStoreConfig

@Configuration
public class TokenStoreConfig {
    @Autowired
    private RedisConnectionFactory redisConnectionFactory;

    @Bean
    public TokenStore redisTokenStore() {
        return new RedisTokenStore(redisConnectionFactory);
    }
}

然后在认证服务器中这个指定该存储策略:

@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替换默认令牌只需要指定TokenStoreJwtTokenStore即可。

创建一个JWTokenConfig配置类:

@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;
    }
}

在认证服务器中指定:

@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接口:

@RestController
public class IndexController {

    @GetMapping("/index")
    public Object index(@AuthenticationPrincipal Authentication authentication) {
        return authentication;
    }
}

重启服务器,获取令牌:

{
    "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增强器):

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:

@Bean
public TokenEnhancer tokenEnhancer() {
    return new JWTokenEnhancer();
}

最后在认证服务器中配置Token增强器:

@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);
}

重启项目,再次获取令牌,结果如下:

{
    "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解析结果:

@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,结果如下:

{
    "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多模块项目,包含认证服务器和两个客户端。

sso
├── sso-server   认证服务器
├── sso-app-one  客户端1
└── sso-app-two  客户端2

创建一个项目,引入依赖:

<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 认证服务器

认证服务器的作用就是作为统一令牌发放并校验。

配置如下:

server:
  port: 8080
  servlet:
    context-path: /server

新建一个Spring Security配置类WebSecurityConfig,继承WebSecurityConfigurerAdpter

@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实现自定义用户登录逻辑:

@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配置认证服务:

@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的支持:

@EnableOAuth2Sso
@SpringBootApplication
public class SsoOneApplication {
    public static void main(String[] args) {
        SpringApplication.run(SsoOneApplication.class, args);
    }
}

重点在于配置,两个客户端的配置如下:

# sso-app-one
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-idsecurity.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的页面:

<!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

@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配置:

clients.inMemory()
    .withClient("app-one")
    ...
    .autoApprove(true)
    ...

autoApprove(true)实现自动授权。

9.5 权限校验

在单点登录模式下进行权限校验,只需要修改客户端。

在客户端新增Spring Security配置类:

@Configuration
@EnableGlobalMethodSecurity(prePostEnabled = true)
public class WebSecurityConfig {
}

在客户端的REST接口中添加接口用于测试:

@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,无权限,说明注解生效。