avatar


27.SpringSecurity

简介

SpringSecurity

官网:https://spring.io/projects/spring-security
功能:

  • 身份认证(authentication)
    身份认证:验证谁正在访问系统资源,判断用户是否为合法用户,认证用户的常见方式是要求用户输入用户名和密码。
  • 授权(authorization)
    用户进行身份认证后,系统会控制"谁能访问哪些资源",这个过程叫做授权,用户无法访问没有权限的资源。
  • 防御常见攻击(protection against common attacks)

入门案例

创建SpringBoot项目

我们基于Spring Initializr创建项目,具体过程可以参考《21.SpringBoot [1/3]》

需要添加三个依赖:Spring WebSpringSecurityThymeleaf(非必需,仅本文演示需要)。

三个依赖

代码结构

IndexController.java

1
2
3
4
5
6
7
8
9
10
11
12
package com.kakawanyifan.controller;

import org.springframework.stereotype.Controller;
import org.springframework.web.bind.annotation.GetMapping;

@Controller
public class IndexController {
@GetMapping("/")
public String index() {
return "index";
}
}

在路径resources/templates下新建index.html

1
2
3
4
5
6
7
8
9
10
<html xmlns:th="https://www.thymeleaf.org">
<head>
<title>Hello Security!</title>
</head>
<body>
<h1>Hello Security</h1>
<!--通过使用@{/logout},Thymeleaf将自动处理生成正确的URL,以适应当前的上下文路径。这样,无论应用程序部署在哪个上下文路径下,生成的URL都能正确地指向注销功能。-->
<a th:href="@{/logout}">Log Out</a>
</body>
</html>

效果

我们启动项目,运行日志如下:

1
2
3
4
5
6
7
8
9
10
11
12
13

【部分运行结果略】

Using generated security password: 3b370801-c87d-43d9-961d-5b9743b5643e

This generated password is for development use only. Your security configuration must be updated before running your application in production.

2024-03-24 09:16:59.519 INFO 1890 --- [ main] o.s.s.web.DefaultSecurityFilterChain : Will secure any request with [org.springframework.security.web.session.DisableEncodeUrlFilter@53ac845a, org.springframework.security.web.context.request.async.WebAsyncManagerIntegrationFilter@5136207f, org.springframework.security.web.context.SecurityContextPersistenceFilter@2c708440, org.springframework.security.web.header.HeaderWriterFilter@cc239ba, org.springframework.security.web.csrf.CsrfFilter@234a8f27, org.springframework.security.web.authentication.logout.LogoutFilter@748e9b20, org.springframework.security.web.authentication.UsernamePasswordAuthenticationFilter@35835e65, org.springframework.security.web.authentication.ui.DefaultLoginPageGeneratingFilter@3a2b2322, org.springframework.security.web.authentication.ui.DefaultLogoutPageGeneratingFilter@26a4551a, org.springframework.security.web.authentication.www.BasicAuthenticationFilter@4992613f, org.springframework.security.web.savedrequest.RequestCacheAwareFilter@6cf31447, org.springframework.security.web.servletapi.SecurityContextHolderAwareRequestFilter@66e8997c, org.springframework.security.web.authentication.AnonymousAuthenticationFilter@3fdecce, org.springframework.security.web.session.SessionManagementFilter@69d6a7cd, org.springframework.security.web.access.ExceptionTranslationFilter@68dcfd52, org.springframework.security.web.access.intercept.FilterSecurityInterceptor@17740dae]
2024-03-24 09:16:59.567 INFO 1890 --- [ main] o.s.b.w.embedded.tomcat.TomcatWebServer : Tomcat started on port(s): 8080 (http) with context path ''
2024-03-24 09:16:59.576 INFO 1890 --- [ main] com.kakawanyifan.Application : Started Application in 1.665 seconds (JVM running for 2.33)
2024-03-24 09:17:07.329 INFO 1890 --- [nio-8080-exec-1] o.a.c.c.C.[Tomcat].[localhost].[/] : Initializing Spring DispatcherServlet 'dispatcherServlet'
2024-03-24 09:17:07.329 INFO 1890 --- [nio-8080-exec-1] o.s.web.servlet.DispatcherServlet : Initializing Servlet 'dispatcherServlet'
2024-03-24 09:17:07.330 INFO 1890 --- [nio-8080-exec-1] o.s.web.servlet.DispatcherServlet : Completed initialization in 1 ms

然后我们在浏览器中访问:http://localhost:8080/
浏览器会自动跳转到登录页:http://localhost:8080/login

登录页

用户名user,密码在上文的日志中有Using generated security password: 3b370801-c87d-43d9-961d-5b9743b5643e

我们也可以将用户名、密码配置在SpringBoot的配置文件中,在application.properties中配置自定义用户名和密码

1
2
spring.security.user.name=user
spring.security.user.password=123

自定义配置

基于内存的用户认证

示例代码:

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
package com.kakawanyifan.config;

import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.security.config.annotation.web.configuration.EnableWebSecurity;
import org.springframework.security.core.userdetails.User;
import org.springframework.security.core.userdetails.UserDetailsService;
import org.springframework.security.provisioning.InMemoryUserDetailsManager;
import org.springframework.security.provisioning.UserDetailsManager;

@Configuration
// Spring项目总需要添加此注解
// SpringBoot项目中不需要,原因可以参考SpringBootAutoConfig的包
@EnableWebSecurity
public class WebSecurityConfig {

@Bean
public UserDetailsService userDetailsService() {
// userDetailsManager对象,管理用户名、密码、角色等。
UserDetailsManager userDetailsManager = new InMemoryUserDetailsManager();
userDetailsManager.createUser(
User.withDefaultPasswordEncoder()
//自定义用户名
.username("usr")
//自定义密码
.password("pwd")
//自定义角色
.roles("USER")
.build()
);
return userDetailsManager;
}
}

解释说明:

  • UserDetailsManager接口用来管理用户信息,InMemoryUserDetailsManagerUserDetailsManager的一个实现,用来管理基于内存的用户信息。
  • 上文我们在application.properties配置的用户名和密码会失效。

基于数据库的用户认证

准备工作

SQL

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
-- 创建数据库
CREATE DATABASE `security-demo`;
USE `security-demo`;

-- 创建用户表
CREATE TABLE `user`
(
`id` INT NOT NULL AUTO_INCREMENT PRIMARY KEY,
`username` VARCHAR(50) DEFAULT NULL,
`password` VARCHAR(500) DEFAULT NULL,
`enabled` BOOLEAN NOT NULL
);
-- 唯一索引
CREATE UNIQUE INDEX `user_username_uindex` ON `user` (`username`);

-- 插入用户数据(密码是 "password" )
INSERT INTO `user` (`username`, `password`, `enabled`)
VALUES ('admin', '{bcrypt}$2a$10$GRLdNijSQMUvl/au9ofL.eDwmoohzzS7.rmNSJZ.0FxO/BTk76klW', TRUE),
('Helen', '{bcrypt}$2a$10$GRLdNijSQMUvl/au9ofL.eDwmoohzzS7.rmNSJZ.0FxO/BTk76klW', TRUE),
('Tom', '{bcrypt}$2a$10$GRLdNijSQMUvl/au9ofL.eDwmoohzzS7.rmNSJZ.0FxO/BTk76klW', TRUE);

引入依赖

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>mysql</groupId>
<artifactId>mysql-connector-java</artifactId>
<version>8.0.30</version>
</dependency>

<dependency>
<groupId>com.baomidou</groupId>
<artifactId>mybatis-plus-boot-starter</artifactId>
<version>3.5.4.1</version>
<exclusions>
<exclusion>
<groupId>org.mybatis</groupId>
<artifactId>mybatis-spring</artifactId>
</exclusion>
</exclusions>
</dependency>

<dependency>
<groupId>org.mybatis</groupId>
<artifactId>mybatis-spring</artifactId>
<version>3.0.3</version>
</dependency>

<dependency>
<groupId>org.projectlombok</groupId>
<artifactId>lombok</artifactId>
</dependency>

配置数据源

1
2
3
4
5
6
7
8
9
# MySQL数据源
spring.datasource.driver-class-name=com.mysql.cj.jdbc.Driver
spring.datasource.url=jdbc:mysql://localhost:3306/security-demo
spring.datasource.username=【用户名】
spring.datasource.password=【密码】
# SQL日志
mybatis-plus.configuration.log-impl=org.apache.ibatis.logging.stdout.StdOutImpl
mybatis-plus.mapper-locations=classpath*:mapper/*.xml
mybatis-plus.type-aliases-package=com.kakawanyifan.entity

实体类

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
package com.kakawanyifan.entity;

import com.baomidou.mybatisplus.annotation.IdType;
import com.baomidou.mybatisplus.annotation.TableId;
import lombok.Data;

@Data
public class User {

@TableId(value = "id", type = IdType.AUTO)
private Integer id;

private String username;

private String password;

private Boolean enabled;

}

Mapper

接口:

1
2
3
4
5
6
7
8
9
10
package com.kakawanyifan.mapper;

import com.baomidou.mybatisplus.core.mapper.BaseMapper;
import com.kakawanyifan.entity.User;
import org.apache.ibatis.annotations.Mapper;

@Mapper
public interface UserMapper extends BaseMapper<User> {

}

resources/mapper/UserMapper.xml

1
2
3
4
5
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE mapper PUBLIC "-//mybatis.org//DTD Mapper 3.0//EN" "http://mybatis.org/dtd/mybatis-3-mapper.dtd">
<mapper namespace="com.atguigu.securitydemo.mapper.UserMapper">

</mapper>

Service

接口:

1
2
3
4
5
6
7
8
package com.kakawanyifan.service;

import com.baomidou.mybatisplus.extension.service.IService;
import com.kakawanyifan.entity.User;

public interface UserService extends IService<User> {

}

实现:

1
2
3
4
5
6
7
8
9
10
11
12
package com.kakawanyifan.service.impl;

import com.baomidou.mybatisplus.extension.service.impl.ServiceImpl;
import com.kakawanyifan.entity.User;
import com.kakawanyifan.mapper.UserMapper;
import com.kakawanyifan.service.UserService;
import org.springframework.stereotype.Service;

@Service
public class UserServiceImpl extends ServiceImpl<UserMapper, User> implements UserService {

}

Controller

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
package com.kakawanyifan.controller;

import com.kakawanyifan.entity.User;
import com.kakawanyifan.service.UserService;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RestController;

import javax.annotation.Resource;
import java.util.List;

@RestController
@RequestMapping("/user")
public class UserController {

@Resource
public UserService userService;

@GetMapping("/list")
public List<User> getList(){
return userService.list();
}
}

测试

我们访问http://localhost:8080/user/list,输入用户名和密码,会得到如下结果。

测试

DBUserDetailsManager

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
package com.kakawanyifan.config;

import com.baomidou.mybatisplus.core.conditions.query.QueryWrapper;
import com.kakawanyifan.entity.User;
import com.kakawanyifan.mapper.UserMapper;
import org.springframework.security.core.GrantedAuthority;
import org.springframework.security.core.userdetails.UserDetails;
import org.springframework.security.core.userdetails.UserDetailsPasswordService;
import org.springframework.security.core.userdetails.UsernameNotFoundException;
import org.springframework.security.provisioning.UserDetailsManager;

import javax.annotation.Resource;
import java.util.ArrayList;
import java.util.Collection;

public class DBUserDetailsManager implements UserDetailsManager, UserDetailsPasswordService {

@Resource
private UserMapper userMapper;

@Override
public UserDetails loadUserByUsername(String username) throws UsernameNotFoundException {

QueryWrapper<User> queryWrapper = new QueryWrapper<>();
queryWrapper.eq("username", username);
User user = userMapper.selectOne(queryWrapper);
if (user == null) {
throw new UsernameNotFoundException(username);
} else {
Collection<GrantedAuthority> authorities = new ArrayList<>();
return new org.springframework.security.core.userdetails.User(
user.getUsername(),
user.getPassword(),
user.getEnabled(),
// accountNonExpired
true,
// credentialsNonExpired
true,
// accountNonLocked
true,
// authorities
authorities);
}
}

@Override
public UserDetails updatePassword(UserDetails user, String newPassword) {
return null;
}

@Override
public void createUser(UserDetails user) {

}

@Override
public void updateUser(UserDetails user) {

}

@Override
public void deleteUser(String username) {

}

@Override
public void changePassword(String oldPassword, String newPassword) {

}

@Override
public boolean userExists(String username) {
return false;
}
}

WebSecurityConfig

修改WebSecurityConfig中的userDetailsService方法如下

1
2
3
4
5
@Bean
public UserDetailsService userDetailsService() {
DBUserDetailsManager manager = new DBUserDetailsManager();
return manager;
}

注意:我们也可以直接在DBUserDetailsManager类上添加@Component注解。

然后我们输入用户名admin、密码password,进行验证登录。

添加用户功能

Controller

UserController中添加方法:

1
2
3
4
@PostMapping("/add")
public void add(@RequestBody User user){
userService.saveUserDetails(user);
}

Service

UserService接口中添加方法saveUserDetails

1
2
3
4
5
6
7
8
9
package com.kakawanyifan.service;

import com.baomidou.mybatisplus.extension.service.IService;
import com.kakawanyifan.entity.User;

public interface UserService extends IService<User> {

void saveUserDetails(User user);
}

UserServiceImpl实现中添加方法saveUserDetails

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
package com.kakawanyifan.service.impl;

import com.baomidou.mybatisplus.extension.service.impl.ServiceImpl;
import com.kakawanyifan.config.DBUserDetailsManager;
import com.kakawanyifan.entity.User;
import com.kakawanyifan.mapper.UserMapper;
import com.kakawanyifan.service.UserService;
import org.springframework.security.core.userdetails.UserDetails;
import org.springframework.stereotype.Service;

import javax.annotation.Resource;
import java.util.ArrayList;

@Service
public class UserServiceImpl extends ServiceImpl<UserMapper, User> implements UserService {

@Resource
private DBUserDetailsManager dbUserDetailsManager;

@Override
public void saveUserDetails(User user) {

UserDetails userDetails = org.springframework.security.core.userdetails.User
.withDefaultPasswordEncoder()
// 自定义用户名
.username(user.getUsername())
// 自定义密码
.password(user.getPassword())
.authorities(new ArrayList<>())
.build();
dbUserDetailsManager.createUser(userDetails);

}
}

DBUserDetailsManager

DBUserDetailsManager中添加方法:

1
2
3
4
5
6
7
8
@Override
public void createUser(UserDetails user) {
User u = new User();
u.setUsername(user.getUsername());
u.setPassword(user.getPassword());
u.setEnabled(true);
userMapper.insert(u);
}

使用Swagger测试

pom.xml中添加配置:

1
2
3
4
5
<dependency>
<groupId>com.github.xiaoymin</groupId>
<artifactId>knife4j-openapi3-spring-boot-starter</artifactId>
<version>4.4.0</version>
</dependency>

Swagger测试地址:http://localhost:8080/doc.html

关闭csrf攻击防御

我们通过Sawgger保存,可能会失败。

因为,默认情况下SpringSecurity开启了csrf攻击防御的功能,这要求请求参数中必须有一个隐藏的_csrf字段,如下:

_csrf

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
package com.kakawanyifan.config;

import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.security.config.annotation.web.builders.HttpSecurity;
import org.springframework.security.config.annotation.web.configuration.EnableWebSecurity;
import org.springframework.security.web.SecurityFilterChain;

import static org.springframework.security.config.Customizer.withDefaults;

@Configuration
// Spring项目总需要添加此注解
// SpringBoot项目中不需要,原因可以参考SpringBootAutoConfig的包
@EnableWebSecurity
public class WebSecurityConfig {

@Bean
public SecurityFilterChain filterChain(HttpSecurity http) throws Exception {
// authorizeRequests():开启授权保护
// anyRequest():对所有请求开启授权保护
// authenticated():已认证请求会自动被授权
http
.authorizeRequests(authorize -> authorize.anyRequest().authenticated())
// 表单授权方式
.formLogin(withDefaults())
// 基本授权方式
.httpBasic(withDefaults());

// 关闭csrf攻击防御
http.csrf((csrf) -> {
csrf.disable();
});
return http.build();
}
}

解释说明:SecurityFilterChain filterChain方法默认也会有。在本文,我们再写一遍,然后关闭csrf攻击防御。

1
2
3
http.csrf((csrf) -> {
csrf.disable();
});

密码加密算法

密码加密方式

  • 明文密码
  • Hash算法
    SpringSecurity的PasswordEncoder接口用于对密码进行单向转换,从而将密码安全地存储。对密码单向转换需要用到哈希算法,例如MD5SHA-256SHA-512等,哈希算法是单向的,只能加密,不能解密
    因此,数据库中存储的是单向转换后的密码,SpringSecurity在进行用户身份验证时需要将用户输入的密码进行单向转换,然后与数据库的密码进行比较。
    因此,如果发生数据泄露,只有密码的单向哈希会被暴露。由于哈希是单向的,并且在给定哈希的情况下只能通过暴力破解的方式猜测密码
  • 彩虹表
    恶意用户会通过创建彩虹表,进行查找。
    彩虹表是一个庞大的、针对各种可能的字母组合预先生成的哈希值集合。有了彩虹表,可以快速破解各类密码。越是复杂的密码,需要的彩虹表就越大,主流的彩虹表都是100G以上,目前主要的算法有LMNTLMMD5SHA1MYSQLSHA1HALFLMCHALLNTLMCHALLORACLE-SYSTEMMD5-HALF
  • 加盐密码
    为了降低彩虹表的影响,开发人员开始使用加盐密码。不再只使用密码作为哈希函数的输入,而是为每个用户的密码生成随机字节(称为盐)。盐和用户的密码将一起经过哈希函数运算,生成一个唯一的哈希。盐将以明文形式与用户的密码一起存储。然后,当用户尝试进行身份验证时,盐和用户输入的密码一起经过哈希函数运算,再与存储的密码进行比较。唯一的盐意味着彩虹表不再有效,因为对于每个盐和密码的组合,哈希都是不同的。
  • 自适应单向函数
    随着硬件的不断发展,加盐哈希也不再安全。原因是,计算机可以每秒执行数十亿次哈希计算。这意味着我们还是可以轻松地破解每个密码。
    现在,开发人员开始使用自适应单向函数来存储密码。使用自适应单向函数验证密码时,故意占用资源(故意使用大量的CPU、内存或其他资源)。自适应单向函数允许配置一个"工作因子",随着硬件的改进而增加。
    SpringSecurity建议将"工作因子"调整到系统中验证密码需要约一秒钟的时间。这种权衡是为了"让攻击者难以破解密码"。
    自适应单向函数包括bcrypt(默认)、PBKDF2scryptargon2

密码加密测试

在测试类中编写一个测试方法。示例代码:

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
package com.kakawanyifan;

import org.junit.jupiter.api.Test;
import org.springframework.boot.test.context.SpringBootTest;
import org.springframework.security.crypto.bcrypt.BCryptPasswordEncoder;
import org.springframework.security.crypto.password.PasswordEncoder;
import org.springframework.util.Assert;

@SpringBootTest
class ApplicationTests {

@Test
void contextLoads() {
}

@Test
void testPassword() {

// 工作因子,默认值是10,最小值是4,最大值是31,值越大运算速度越慢
PasswordEncoder encoder = new BCryptPasswordEncoder(4);
// 明文:"password"
// 密文:result,即使明文密码相同,每次生成的密文也不一致
String result = encoder.encode("password");
System.out.println(result);

// 密码校验
Assert.isTrue(encoder.matches("password", result), "密码不一致");

}

}

运行结果:

1
$2a$04$naNIaGNnpPyMUhvr1S.Pg.XKVsWEChbzg/jNtSCxujyyrS5Sf5lTi

这个运行结果和我们在数据库中保存的运行结果,存在差异。在数据库中,我们保存如下。有前缀{bcrypt}

1
{bcrypt}$2a$10$GRLdNijSQMUvl/au9ofL.eDwmoohzzS7.rmNSJZ.0FxO/BTk76klW

{bcrypt}用于标识密码的加密方式:在做密码比对的时候会用到,在做密码升级的时候也会用到。

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
package org.springframework.security.crypto.password;

import java.util.HashMap;
import java.util.Map;

【部分代码略】


public class DelegatingPasswordEncoder implements PasswordEncoder {

【部分代码略】

@Override
public boolean matches(CharSequence rawPassword, String prefixEncodedPassword) {
if (rawPassword == null && prefixEncodedPassword == null) {
return true;
}
String id = extractId(prefixEncodedPassword);
PasswordEncoder delegate = this.idToPasswordEncoder.get(id);
if (delegate == null) {
return this.defaultPasswordEncoderForMatches.matches(rawPassword, prefixEncodedPassword);
}
String encodedPassword = extractEncodedPassword(prefixEncodedPassword);
return delegate.matches(rawPassword, encodedPassword);
}

@Override
public boolean upgradeEncoding(String prefixEncodedPassword) {
String id = extractId(prefixEncodedPassword);
if (!this.idForEncode.equalsIgnoreCase(id)) {
return true;
}
else {
String encodedPassword = extractEncodedPassword(prefixEncodedPassword);
return this.idToPasswordEncoder.get(id).upgradeEncoding(encodedPassword);
}
}

}

自定义登录页面

创建登录Controller

1
2
3
4
5
6
7
8
9
10
11
12
13
14
package com.kakawanyifan.controller;


import org.springframework.stereotype.Controller;
import org.springframework.web.bind.annotation.GetMapping;

@Controller
public class LoginController {

@GetMapping("/login")
public String login() {
return "login";
}
}

创建登录页面

resources/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
<!DOCTYPE html>
<html xmlns:th="https://www.thymeleaf.org">
<head>
<title>登录</title>
</head>
<body>
<h1>登录</h1>
<div th:if="${param.error}">
错误的用户名和密码.</div>

<!--method必须为"post"-->
<!--th:action="@{/login}" ,
使用动态参数,表单中会自动生成_csrf隐藏字段,用于防止csrf攻击
login: 和登录页面保持一致即可,SpringSecurity自动进行登录认证-->
<form th:action="@{/login}" method="post">
<div>
<!--name必须为"username"-->
<input type="text" name="username" placeholder="用户名"/>
</div>
<div>
<!--name必须为"password"-->
<input type="password" name="password" placeholder="密码"/>
</div>
<input type="submit" value="登录" />
</form>
</body>
</html>

配置SecurityFilterChain

示例代码:

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
package com.kakawanyifan.config;

import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.security.config.annotation.web.builders.HttpSecurity;
import org.springframework.security.config.annotation.web.configuration.EnableWebSecurity;
import org.springframework.security.web.SecurityFilterChain;

import static org.springframework.security.config.Customizer.withDefaults;

@Configuration
// Spring项目总需要添加此注解
// SpringBoot项目中不需要,原因可以参考SpringBootAutoConfig的包
@EnableWebSecurity
public class WebSecurityConfig {

@Bean
public SecurityFilterChain filterChain(HttpSecurity http) throws Exception {
// authorizeRequests():开启授权保护
// anyRequest():对所有请求开启授权保护
// authenticated():已认证请求会自动被授权
http
.authorizeRequests(authorize -> authorize.anyRequest().authenticated())
// 表单授权方式
.formLogin(
form -> {
form
// 登录页面无需授权即可访问
.loginPage("/login").permitAll()
// 登录失败的返回地址
.failureUrl("/login?error");
}
)
// 基本授权方式
.httpBasic(withDefaults());

// 关闭csrf攻击防御
http.csrf((csrf) -> {
csrf.disable();
});
return http.build();
}
}

重点关注:

1
2
3
4
5
6
7
8
9
10
// 表单授权方式
.formLogin(
form -> {
form
// 登录页面无需授权即可访问
.loginPage("/login").permitAll()
// 登录失败的返回地址
.failureUrl("/login?error");
}
)

自定义字段名

表单的字段名是usernamepassword,如果需要修改可以添加如下两行

1
2
.usernameParameter("uid")
.passwordParameter("pwd")
  • 改成uidpwd
1
2
3
4
5
6
7
8
9
10
11
12
// 表单授权方式
.formLogin(
form -> {
form
// 登录页面无需授权即可访问
.loginPage("/login").permitAll()
.usernameParameter("uid")
.passwordParameter("pwd")
// 登录失败的返回地址
.failureUrl("/login?error");
}
)

前后端分离

认证过程

认证过程如下:

usernamepasswordauthenticationfilter

  • 登录成功后调用:AuthenticationSuccessHandler
  • 登录失败后调用:AuthenticationFailureHandler

认证成功的响应

成功结果处理

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
package com.kakawanyifan.handler;

import com.alibaba.fastjson2.JSON;
import org.springframework.security.core.Authentication;
import org.springframework.security.web.authentication.AuthenticationSuccessHandler;

import javax.servlet.ServletException;
import javax.servlet.http.HttpServletRequest;
import javax.servlet.http.HttpServletResponse;
import java.io.IOException;
import java.util.HashMap;

public class MyAuthenticationSuccessHandler implements AuthenticationSuccessHandler {
@Override
public void onAuthenticationSuccess(HttpServletRequest request, HttpServletResponse response, Authentication authentication) throws IOException, ServletException {

//获取用户身份信息
Object principal = authentication.getPrincipal();

//创建结果对象
HashMap result = new HashMap();
result.put("code", 0);
result.put("message", "登录成功");
result.put("data", principal);

//转换成json字符串
String json = JSON.toJSONString(result);

//返回响应
response.setContentType("application/json;charset=UTF-8");
response.getWriter().println(json);
}
}

需要添加依赖:

1
2
3
4
5
<dependency>
<groupId>com.alibaba.fastjson2</groupId>
<artifactId>fastjson2</artifactId>
<version>2.0.37</version>
</dependency>

SecurityFilterChain配置

添加如下代码:

1
2
//认证成功时的处理
.successHandler(new 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
package com.kakawanyifan.config;

import com.kakawanyifan.handler.MyAuthenticationSuccessHandler;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.security.config.annotation.web.builders.HttpSecurity;
import org.springframework.security.config.annotation.web.configuration.EnableWebSecurity;
import org.springframework.security.web.SecurityFilterChain;

import static org.springframework.security.config.Customizer.withDefaults;

@Configuration
// Spring项目总需要添加此注解
// SpringBoot项目中不需要,原因可以参考SpringBootAutoConfig的包
@EnableWebSecurity
public class WebSecurityConfig {

@Bean
public SecurityFilterChain filterChain(HttpSecurity http) throws Exception {
// authorizeRequests():开启授权保护
// anyRequest():对所有请求开启授权保护
// authenticated():已认证请求会自动被授权
http
.authorizeRequests(authorize -> authorize.anyRequest().authenticated())
//表单授权方式
.formLogin(
form -> {
form
//登录页面无需授权即可访问
.loginPage("/login").permitAll()
.usernameParameter("uid")
.passwordParameter("pwd")
//登录失败的返回地址
.failureUrl("/login?error")
//认证成功时的处理
.successHandler(new MyAuthenticationSuccessHandler());
}
)
//基本授权方式
.httpBasic(withDefaults());

//关闭csrf攻击防御
http.csrf((csrf) -> {
csrf.disable();
});
return http.build();
}
}

认证失败响应

失败结果处理

示例代码:

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
package com.kakawanyifan.handler;

import com.alibaba.fastjson2.JSON;
import org.springframework.security.core.AuthenticationException;
import org.springframework.security.web.authentication.AuthenticationFailureHandler;

import javax.servlet.ServletException;
import javax.servlet.http.HttpServletRequest;
import javax.servlet.http.HttpServletResponse;
import java.io.IOException;
import java.util.HashMap;

public class MyAuthenticationFailureHandler implements AuthenticationFailureHandler {

@Override
public void onAuthenticationFailure(HttpServletRequest request, HttpServletResponse response, AuthenticationException exception) throws IOException, ServletException {

//获取错误信息
String localizedMessage = exception.getLocalizedMessage();

//创建结果对象
HashMap result = new HashMap();
result.put("code", -1);
result.put("message", localizedMessage);

//转换成json字符串
String json = JSON.toJSONString(result);

//返回响应
response.setContentType("application/json;charset=UTF-8");
response.getWriter().println(json);
}
}

SecurityFilterChain配置

添加如下代码:

1
2
//认证失败时的处理
.failureHandler(new MyAuthenticationFailureHandler())

注销响应

注销结果处理

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
package com.kakawanyifan.handler;

import com.alibaba.fastjson2.JSON;
import org.springframework.security.core.Authentication;
import org.springframework.security.web.authentication.logout.LogoutSuccessHandler;

import javax.servlet.ServletException;
import javax.servlet.http.HttpServletRequest;
import javax.servlet.http.HttpServletResponse;
import java.io.IOException;
import java.util.HashMap;

public class MyLogoutSuccessHandler implements LogoutSuccessHandler {

@Override
public void onLogoutSuccess(HttpServletRequest request, HttpServletResponse response, Authentication authentication) throws IOException, ServletException {

//创建结果对象
HashMap result = new HashMap();
result.put("code", 0);
result.put("message", "注销成功");

//转换成json字符串
String json = JSON.toJSONString(result);

//返回响应
response.setContentType("application/json;charset=UTF-8");
response.getWriter().println(json);
}
}

SecurityFilterChain配置

添加如下代码:

1
2
3
4
//注销成功时的处理
http.logout(logout -> {
logout.logoutSuccessHandler(new MyLogoutSuccessHandler());
});

请求未认证的接口

实现AuthenticationEntryPoint接口

当访问一个需要认证之后才能访问的接口的时候,SpringSecurity会使用AuthenticationEntryPoint将用户请求跳转到登录页面,要求用户提供登录凭证。

这里我们也希望系统"返回JSON结果",因此我们定义类实现AuthenticationEntryPoint接口。

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
package com.kakawanyifan.handler;

import com.alibaba.fastjson2.JSON;
import org.springframework.security.core.AuthenticationException;
import org.springframework.security.web.AuthenticationEntryPoint;

import javax.servlet.ServletException;
import javax.servlet.http.HttpServletRequest;
import javax.servlet.http.HttpServletResponse;
import java.io.IOException;
import java.util.HashMap;

public class MyAuthenticationEntryPoint implements AuthenticationEntryPoint {
@Override
public void commence(HttpServletRequest request, HttpServletResponse response, AuthenticationException authException) throws IOException, ServletException {

// 获取错误信息
// String localizedMessage = authException.getLocalizedMessage();

// 创建结果对象
HashMap result = new HashMap();
result.put("code", -1);
result.put("message", "需要登录");

// 转换成json字符串
String json = JSON.toJSONString(result);

// 返回响应
response.setContentType("application/json;charset=UTF-8");
response.getWriter().println(json);
}
}

SecurityFilterChain配置

添加如下代码:

1
2
3
4
// 错误处理
http.exceptionHandling(exception -> {
exception.authenticationEntryPoint(new MyAuthenticationEntryPoint());//请求未认证的接口
});

跨域

跨域全称是跨域资源共享(Cross-Origin Resources Sharing,CORS),它是浏览器的保护机制,只允许网页请求统一域名下的服务。
这里的同一域名指协议域名端口号都要保持一致,如果有一项不同,那么就是跨域请求。

在前后端分离的项目中,需要解决跨域的问题。

在SpringSecurity中解决跨域很简单,在配置文件中添加如下配置即可:

1
2
// 跨域
http.cors(withDefaults());

身份认证

用户认证信息

基本概念

用户认证信息

  • SecurityContextHolder用于管理当前线程的安全上下文,存储已认证用户的详细信息。
  • 其中包含了SecurityContext对象,该对象包含了Authentication对象。
  • Authentication对象表示用户的身份验证信息,包括Principal(用户的身份标识)和Credential(用户的凭证信息)。

例子

这些信息,我们在上文"前后端分离"部分,都已经用过了。
在这里,我们试图在Controller中获取用户信息。

示例代码:

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
package com.kakawanyifan.controller;

import org.springframework.security.core.Authentication;
import org.springframework.security.core.GrantedAuthority;
import org.springframework.security.core.context.SecurityContext;
import org.springframework.security.core.context.SecurityContextHolder;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.RestController;

import java.util.Collection;
import java.util.HashMap;
import java.util.Map;

@RestController
public class IndexController {

@GetMapping("/")
public Map index(){

System.out.println("index controller");

// 存储认证对象的上下文
SecurityContext securityContext = SecurityContextHolder.getContext();
// 认证对象
Authentication authentication = securityContext.getAuthentication();
// 用户名
String username = authentication.getName();
// 身份
Object principal = authentication.getPrincipal();
// 凭证(脱敏)
Object credentials = authentication.getCredentials();
// 权限
Collection<? extends GrantedAuthority> authorities = authentication.getAuthorities();

System.out.println(username);
System.out.println(principal);
System.out.println(credentials);
System.out.println(authorities);

//创建结果对象
HashMap result = new HashMap();
result.put("code", 0);
result.put("data", username);

return result;
}

}

运行结果:

1
2
3
4
5
index controller
admin
org.springframework.security.core.userdetails.User [Username=admin, Password=[PROTECTED], Enabled=true, AccountNonExpired=true, credentialsNonExpired=true, AccountNonLocked=true, Granted Authorities=[]]
null
[]

会话并发处理

需求

后登录的账号会使先登录的账号失效

实现处理器接口

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
package com.kakawanyifan.strategy;

import com.alibaba.fastjson2.JSON;
import org.springframework.security.web.session.SessionInformationExpiredEvent;
import org.springframework.security.web.session.SessionInformationExpiredStrategy;

import javax.servlet.ServletException;
import javax.servlet.http.HttpServletResponse;
import java.io.IOException;
import java.util.HashMap;

public class MySessionInformationExpiredStrategy implements SessionInformationExpiredStrategy {
@Override
public void onExpiredSessionDetected(SessionInformationExpiredEvent event) throws IOException, ServletException {

//创建结果对象
HashMap result = new HashMap();
result.put("code", -1);
result.put("message", "该账号已从其他设备登录");

//转换成json字符串
String json = JSON.toJSONString(result);

HttpServletResponse response = event.getResponse();
//返回响应
response.setContentType("application/json;charset=UTF-8");
response.getWriter().println(json);
}
}

SecurityFilterChain配置

添加如下代码:

1
2
3
4
5
6
//会话管理
http.sessionManagement(session -> {
session
.maximumSessions(1)
.expiredSessionStrategy(new MySessionInformationExpiredStrategy());
});

授权

分类

按照授权方式分类,有:

  1. 用户-权限-资源
    例如:具有USER_LIST权限的用户可以访问/user/list接口,具有USER_ADD权限的用户可以访问/user/add接口。
  2. 用户-角色-资源
    例如:角色为ADMIN的用户才可以访问/user/**路径下的资源。
  3. 用户-角色-权限-资源

按照实现方法分类,有:

  1. 基于request的授权
  2. 基于方法的授权

基于request的授权

用户-权限-资源

需求背景

具有USER_LIST权限的用户可以访问/user/list接口,具有USER_ADD权限的用户可以访问/user/add接口。

配置权限

添加如下部分:

1
2
3
4
5
6
7
8
9
10
11
12
//开启授权保护
http.authorizeRequests(
authorize -> authorize
// 具有USER_LIST权限的用户可以访问/user/list
.antMatchers("/user/list").hasAuthority("USER_LIST")
// 具有USER_ADD权限的用户可以访问/user/add
.antMatchers("/user/add").hasAuthority("USER_ADD")
// 对所有请求开启授权保护
.anyRequest()
// 已认证的请求会被自动授权
.authenticated()
);
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
package com.kakawanyifan.config;

import com.alibaba.fastjson2.JSON;
import com.kakawanyifan.handler.MyAuthenticationEntryPoint;
import com.kakawanyifan.handler.MyAuthenticationFailureHandler;
import com.kakawanyifan.handler.MyAuthenticationSuccessHandler;
import com.kakawanyifan.handler.MyLogoutSuccessHandler;
import com.kakawanyifan.strategy.MySessionInformationExpiredStrategy;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.security.config.annotation.web.builders.HttpSecurity;
import org.springframework.security.config.annotation.web.configuration.EnableWebSecurity;
import org.springframework.security.web.SecurityFilterChain;

import java.util.HashMap;

import static org.springframework.security.config.Customizer.withDefaults;

@Configuration
// Spring项目总需要添加此注解
// SpringBoot项目中不需要,原因可以参考SpringBootAutoConfig的包
@EnableWebSecurity
public class WebSecurityConfig {

@Bean
public SecurityFilterChain filterChain(HttpSecurity http) throws Exception {
// authorizeRequests():开启授权保护
// anyRequest():对所有请求开启授权保护
// authenticated():已认证请求会自动被授权
// http.authorizeRequests(authorize -> authorize.anyRequest().authenticated());

//开启授权保护
http.authorizeRequests(
authorize -> authorize
// 具有USER_LIST权限的用户可以访问/user/list
.antMatchers("/user/list").hasAuthority("USER_LIST")
// 具有USER_ADD权限的用户可以访问/user/add
.antMatchers("/user/add").hasAuthority("USER_ADD")
// 对所有请求开启授权保护
.anyRequest()
// 已认证的请求会被自动授权
.authenticated()
);

//表单授权方式
http.formLogin(
form -> {
form
//登录页面无需授权即可访问
.loginPage("/login").permitAll()
//登录失败的返回地址
.failureUrl("/login?error")
//认证成功时的处理
.successHandler(new MyAuthenticationSuccessHandler())
//认证失败时的处理
.failureHandler(new MyAuthenticationFailureHandler());
}
)
//基本授权方式
.httpBasic(withDefaults());

//关闭csrf攻击防御
http.csrf((csrf) -> {
csrf.disable();
});

//注销成功时的处理
http.logout(logout -> {
logout.logoutSuccessHandler(new MyLogoutSuccessHandler());
});

// 错误处理
http.exceptionHandling(exception -> {
exception.authenticationEntryPoint(new MyAuthenticationEntryPoint());//请求未认证的接口
});

// 跨域
http.cors(withDefaults());

//会话管理
http.sessionManagement(session -> {
session
.maximumSessions(1)
.expiredSessionStrategy(new MySessionInformationExpiredStrategy());
});

//错误处理
http.exceptionHandling(exception -> {
exception.authenticationEntryPoint(new MyAuthenticationEntryPoint());//请求未认证的接口
exception.accessDeniedHandler((request, response, e)->{ //请求未授权的接口

//创建结果对象
HashMap result = new HashMap();
result.put("code", -1);
result.put("message", "没有权限");

//转换成json字符串
String json = JSON.toJSONString(result);

//返回响应
response.setContentType("application/json;charset=UTF-8");
response.getWriter().println(json);
});
});


return http.build();
}
}

授予权限

修改DBUserDetailsManager中的loadUserByUsername方法:

1
2
3
4
5
6
7
8
9
10
11
12
13
Collection<GrantedAuthority> authorities = new ArrayList<>();
// authorities.add(new GrantedAuthority() {
// @Override
// public String getAuthority() {
// return "USER_LIST";
// }
// });
authorities.add(new GrantedAuthority() {
@Override
public String getAuthority() {
return "USER_ADD";
}
});

我们也采取如下写法:

1
2
3
Collection<GrantedAuthority> authorities = new ArrayList<>();
authorities.add(()->"USER_LIST");
authorities.add(()->"USER_ADD");

请求未授权的接口

添加如下代码:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
// 错误处理
http.exceptionHandling(exception -> {
exception.authenticationEntryPoint(new MyAuthenticationEntryPoint());//请求未认证的接口
exception.accessDeniedHandler((request, response, e)->{ //请求未授权的接口

//创建结果对象
HashMap result = new HashMap();
result.put("code", -1);
result.put("message", "没有权限");

//转换成json字符串
String json = JSON.toJSONString(result);

//返回响应
response.setContentType("application/json;charset=UTF-8");
response.getWriter().println(json);
});
});

用户-角色-资源

需求背景

角色为ADMIN的用户才可以访问/user/**路径下的资源。

配置角色

1
2
3
4
5
6
7
8
9
10
//开启授权保护
http.authorizeRequests(
authorize -> authorize
//具有管理员角色的用户可以访问/user/**
.antMatchers("/user/**").hasRole("ADMIN")
//对所有请求开启授权保护
.anyRequest()
//已认证的请求会被自动授权
.authenticated()
);

授予角色

添加如下代码:

1
2
3
4
5
return org.springframework.security.core.userdetails.User
.withUsername(user.getUsername())
.password(user.getPassword())
.roles("ADMIN")
.build();

并删除如下代码:

1
2
3
4
5
6
7
8
9
10
11
12
return new org.springframework.security.core.userdetails.User(
user.getUsername(),
user.getPassword(),
user.getEnabled(),
// accountNonExpired
true,
// credentialsNonExpired
true,
// accountNonLocked
true,
// authorities
authorities);

用户-角色-权限-资源

什么是用户-角色-权限-资源

RBAC,Role-Based Access Control,基于角色的访问控制,将用户的权限分配和管理与角色相关联。

数据库设计

一、用户表,包含用户的基本信息,例如用户名、密码和其他身份验证信息。

列名 数据类型 描述
user_id int 用户ID
username varchar 用户名
password varchar 密码
email varchar 电子邮件地址

二、角色表,存储所有可能的角色及其描述。

列名 数据类型 描述
role_id int 角色ID
role_name varchar 角色名称
description varchar 角色描述

三、权限表,定义系统中所有可能的权限。

列名 数据类型 描述
permission_id int 权限ID
permission_name varchar 权限名称
description varchar 权限描述

四、用户角色关联表,将用户与角色关联起来。

列名 数据类型 描述
user_role_id int 用户角色关联ID
user_id int 用户ID
role_id int 角色ID

五、角色权限关联表,将角色与权限关联起来。

列名 数据类型 描述
role_permission_id int 角色权限关联ID
role_id int 角色ID
permission_id int 权限ID

在这个设计方案中,用户可以被分配一个或多个角色,而每个角色又可以具有一个或多个权限。通过对用户角色关联和角色权限关联表进行操作,可以实现灵活的权限管理和访问控制。

当用户尝试访问系统资源时,系统可以根据用户的角色和权限决定是否允许访问。这样的设计方案使得权限管理更加简单和可维护,因为只需调整角色和权限的分配即可,而不需要针对每个用户进行单独的设置。

代码实现

那么,怎么实现呢?

和"用户-权限-资源"没有区别,只是在获取用户权限的时候,需要关联查询一下。

基于方法的授权

开启方法授权

在配置文件中添加如下注解

1
@EnableMethodSecurity
1
2
3
4
5
6
7
8
9
10
import org.springframework.security.config.annotation.method.configuration.EnableMethodSecurity;

【部分代码略】

@EnableMethodSecurity
public class WebSecurityConfig {

【部分代码略】

}

给用户授予角色和权限

修改DBUserDetailsManager中的loadUserByUsername方法:

1
2
3
4
5
6
return org.springframework.security.core.userdetails.User
.withUsername(user.getUsername())
.password(user.getPassword())
// .roles("ADMIN")
.authorities("USER_ADD", "USER_UPDATE")
.build();

注意:

  • .authorities("USER_ADD", "USER_UPDATE"),如果我们想添加多个权限,不能写两遍.authorities(),应该传入一个String... authorities
  • .authorities不但会覆盖前一个.authorities,还会覆盖.roles。即两个不能同时使用。

常用授权注解

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
package com.kakawanyifan.controller;

import com.kakawanyifan.entity.User;
import com.kakawanyifan.service.UserService;
import org.springframework.security.access.prepost.PreAuthorize;
import org.springframework.web.bind.annotation.*;

import javax.annotation.Resource;
import java.util.List;

@RestController
@RequestMapping("/user")
public class UserController {

@Resource
public UserService userService;

//用户必须有 ADMIN 角色 并且 用户名是 admin 才能访问此方法
@PreAuthorize("hasRole('ADMIN') and authentication.name == 'admim'")
@GetMapping("/list")
public List<User> getList(){
return userService.list();
}

//用户必须有 USER_ADD 权限 才能访问此方法
@PreAuthorize("hasAuthority('USER_ADD')")
@PostMapping("/add")
public void add(@RequestBody User user){
userService.saveUserDetails(user);
}
}

OAuth2

简介

什么是OAuth2

“Auth"表示"Authorization”,授权。
“O"表示"Open”,开放。

连在一起,就是"开放授权",即一种开放授权协议。

(“2”,表示第二个版本。)

OAuth2的角色

OAuth2协议包含以下角色:

  1. 资源所有者(Resource Owner)
    用户,资源的拥有人,想要通过客户应用访问资源服务器上的资源。
  2. 客户应用(Client)
    通常是一个Web或者无线应用,它需要访问用户的受保护资源。
  3. 资源服务器(Resource Server)
    存储受保护资源的服务器或定义了可以访问到资源的API,接收并验证客户端的访问令牌,以决定是否授权访问资源。
  4. 授权服务器(Authorization Server)
    负责验证资源所有者的身份并向客户端颁发访问令牌。

OAuth2的角色

使用场景

开放系统间授权

  1. 社交登录
  2. 开放API
    例如,我们在百度网盘有很多照片,然后我们通过一个微信小程序冲印我们的照片;一般的操作是我们需要去百度网盘下载照片,然后上传到微信小程序;现在我们可以直接授权微信小程序访问我们百度网盘。

企业内部应用认证授权

  1. SSO,Single Sign On,单点登录。
  2. IAM,Identity and Access Management,身份识别与访问管理。

现代微服务安全

现代微服务安全

OAuth2的四种授权模式

  1. 授权码(authorization-code)
  2. 隐藏式(implicit)
  3. 密码式(password)
  4. 客户端凭证(client credentials)

授权码

授权码,authorization code,指的是第三方应用先申请一个授权码,然后再用该码获取令牌。

这种方式是最常用,最复杂,也是最安全的,它适用于那些有后端的Web应用。授权码通过前端传送,令牌则是储存在后端,而且所有与资源服务器的通信都在后端完成。这样的前后端分离,可以避免令牌泄漏。

授权码

隐藏式

隐藏式,implicit,也叫简化模式,有些Web应用是纯前端应用,没有后端。这时就不能用上面的方式了,必须将令牌储存在前端。

这种方式没有授权码这个中间步骤,所以称为隐藏式。这种方式把令牌直接传给前端,是很不安全的。因此,只能用于一些安全要求不高的场景,并且令牌的有效期必须非常短,通常就是会话期间(session)有效,浏览器关掉,令牌就失效了。

隐藏式

密码式

密码式,Resource Owner Password Credentials,如果我们高度信任某个应用,也可以把用户把用户名和密码,直接告诉该应用。该应用就使用你的密码,申请令牌。

这种方式需要用户给出自己的用户名/密码,显然风险很大,因此只适用于其他授权方式都无法采用的情况,而且必须是用户高度信任的应用。

密码式

凭证式

凭证式,client credentials,也叫客户端模式,适用于没有前端的命令行应用,即在命令行下请求令牌。

这种方式给出的令牌,是针对第三方应用的,而不是针对用户的,即有可能多个用户共享同一个令牌。

凭证式

授权类型的选择

授权类型的选择

Spring中的OAuth2

相关角色

在上文,我们说OAuth2有四个角色

  1. 资源所有者(Resource Owner)
  2. 客户应用(Client)
  3. 资源服务器(Resource Server)
  4. 授权服务器(Authorization Server)

在Spring中角色划分如下:

  • SpringSecurity
    • 客户应用(OAuth2 Client),OAuth2客户端功能中包含OAuth2 Login。
    • 资源服务器(OAuth2 Resource Server)
  • Spring:
    授权服务器(Spring Authorization Server),在SpringSecurity之上的一个单独的项目。

相关依赖

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
<!-- 资源服务器 -->
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-oauth2-resource-server</artifactId>
</dependency>

<!-- 客户应用 -->
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-oauth2-client</artifactId>
</dependency>

<!-- 授权服务器 -->
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-oauth2-authorization-server</artifactId>
</dependency>

授权登录的实现思路

使用OAuth2 Login。

授权登录的实现思路

案例(GiuHub社交登录)

创建应用

登录GitHub,在开发者设置中找到OAuth Apps,创建一个application,为客户应用创建访问GitHub的凭据。
https://github.com/settings/developers

创建应用-1

默认的重定向URI模板为

1
{baseUrl}/login/oauth2/code/{registrationId}
  • registrationIdClientRegistration的唯一标识符。

例如,在我们本机开发中,填写如下:

创建应用-2

获取应用程序ID,生成应用程序密钥。

创建应用-3

创建测试项目

创建一个SpringBoot项目,创建时引入如下依赖

  • Spring Web
  • Thymeleaf(非必需,仅本文演示需要)
  • SpringSecurity
  • Auth2 Client

配置OAuth客户端属性

application.properties:

1
2
3
spring.security.oauth2.client.registration.github.client-id=【Client ID】
spring.security.oauth2.client.registration.github.client-secret=【Client secrets】
# spring.security.oauth2.client.registration.github.redirect-uri=http://localhost:8200/login/oauth2/code/github

Controller

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
package com.kakawanyifan.controller;

import org.springframework.security.core.annotation.AuthenticationPrincipal;
import org.springframework.security.oauth2.client.OAuth2AuthorizedClient;
import org.springframework.security.oauth2.client.annotation.RegisteredOAuth2AuthorizedClient;
import org.springframework.security.oauth2.core.user.OAuth2User;
import org.springframework.stereotype.Controller;
import org.springframework.ui.Model;
import org.springframework.web.bind.annotation.GetMapping;

@Controller
public class IndexController {

@GetMapping("/")
public String index(
Model model,
@RegisteredOAuth2AuthorizedClient OAuth2AuthorizedClient authorizedClient,
@AuthenticationPrincipal OAuth2User oauth2User) {
model.addAttribute("userName", oauth2User.getName());
model.addAttribute("clientName", authorizedClient.getClientRegistration().getClientName());
model.addAttribute("userAttributes", oauth2User.getAttributes());
return "index";
}
}

HTML

templates/index.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
<!DOCTYPE html>
<html xmlns="http://www.w3.org/1999/xhtml" xmlns:th="https://www.thymeleaf.org" xmlns:sec="https://www.thymeleaf.org/thymeleaf-extras-springsecurity5">
<head>
<title>SpringSecurity - OAuth 2.0 Login</title>
<meta charset="utf-8" />
</head>
<body>
<div style="float: right" th:fragment="logout" sec:authorize="isAuthenticated()">
<div style="float:left">
<span style="font-weight:bold">User: </span><span sec:authentication="name"></span>
</div>
<div style="float:none">&nbsp;</div>
<div style="float:right">
<form action="#" th:action="@{/logout}" method="post">
<input type="submit" value="Logout" />
</form>
</div>
</div>
<h1>OAuth 2.0 Login with SpringSecurity</h1>
<div>
You are successfully logged in <span style="font-weight:bold" th:text="${userName}"></span>
via the OAuth 2.0 Client <span style="font-weight:bold" th:text="${clientName}"></span>
</div>
<div>&nbsp;</div>
<div>
<span style="font-weight:bold">User Attributes:</span>
<ul>
<li th:each="userAttribute : ${userAttributes}">
<span style="font-weight:bold" th:text="${userAttribute.key}"></span>: <span th:text="${userAttribute.value}"></span>
</li>
</ul>
</div>
</body>
</html>

效果

然后我们启动程序并访问http://localhost:8080,浏览器将被重定向到默认的自动生成的登录页面。

效果

  • 点击GitHub链接,浏览器将被重定向到GitHub进行身份验证。
  • 使用GitHub账户凭据进行身份验证后,用户会看到授权页面,询问用户是否允许或拒绝客户应用访问GitHub上的用户数据。点击允许以授权OAuth客户端访问用户的基本个人资料信息。
  • 此时,OAuth客户端访问GitHub的获取用户信息的接口获取基本个人资料信息,并建立一个已认证的会话。

CommonOAuth2Provider

CommonOAuth2Provider是一个预定义的通用OAuth2Provider,为一些知名资源服务API提供商(如Google、GitHub、Facebook)预定义了一组默认的属性。

例如,授权URI、令牌URI和用户信息URI通常不经常变化。因此,提供默认值以减少所需的配置。
因此,当我们配置GitHub客户端时,只需要提供client-id和client-secret属性。

org.springframework.security.config.oauth2.client.CommonOAuth2Provider

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
package org.springframework.security.config.oauth2.client;

import org.springframework.security.oauth2.client.registration.ClientRegistration;
import org.springframework.security.oauth2.client.registration.ClientRegistration.Builder;
import org.springframework.security.oauth2.core.AuthorizationGrantType;
import org.springframework.security.oauth2.core.ClientAuthenticationMethod;
import org.springframework.security.oauth2.core.oidc.IdTokenClaimNames;

/**
* Common OAuth2 Providers that can be used to create
* {@link org.springframework.security.oauth2.client.registration.ClientRegistration.Builder
* builders} pre-configured with sensible defaults.
*
* @author Phillip Webb
* @since 5.0
*/
public enum CommonOAuth2Provider {

GOOGLE {

@Override
public Builder getBuilder(String registrationId) {
ClientRegistration.Builder builder = getBuilder(registrationId,
ClientAuthenticationMethod.CLIENT_SECRET_BASIC, DEFAULT_REDIRECT_URL);
builder.scope("openid", "profile", "email");
builder.authorizationUri("https://accounts.google.com/o/oauth2/v2/auth");
builder.tokenUri("https://www.googleapis.com/oauth2/v4/token");
builder.jwkSetUri("https://www.googleapis.com/oauth2/v3/certs");
builder.issuerUri("https://accounts.google.com");
builder.userInfoUri("https://www.googleapis.com/oauth2/v3/userinfo");
builder.userNameAttributeName(IdTokenClaimNames.SUB);
builder.clientName("Google");
return builder;
}

},

GITHUB {

@Override
public Builder getBuilder(String registrationId) {
ClientRegistration.Builder builder = getBuilder(registrationId,
ClientAuthenticationMethod.CLIENT_SECRET_BASIC, DEFAULT_REDIRECT_URL);
builder.scope("read:user");
builder.authorizationUri("https://github.com/login/oauth/authorize");
builder.tokenUri("https://github.com/login/oauth/access_token");
builder.userInfoUri("https://api.github.com/user");
builder.userNameAttributeName("id");
builder.clientName("GitHub");
return builder;
}

},

FACEBOOK {

@Override
public Builder getBuilder(String registrationId) {
ClientRegistration.Builder builder = getBuilder(registrationId,
ClientAuthenticationMethod.CLIENT_SECRET_POST, DEFAULT_REDIRECT_URL);
builder.scope("public_profile", "email");
builder.authorizationUri("https://www.facebook.com/v2.8/dialog/oauth");
builder.tokenUri("https://graph.facebook.com/v2.8/oauth/access_token");
builder.userInfoUri("https://graph.facebook.com/me?fields=id,name,email");
builder.userNameAttributeName("id");
builder.clientName("Facebook");
return builder;
}

},

OKTA {

@Override
public Builder getBuilder(String registrationId) {
ClientRegistration.Builder builder = getBuilder(registrationId,
ClientAuthenticationMethod.CLIENT_SECRET_BASIC, DEFAULT_REDIRECT_URL);
builder.scope("openid", "profile", "email");
builder.userNameAttributeName(IdTokenClaimNames.SUB);
builder.clientName("Okta");
return builder;
}

};

private static final String DEFAULT_REDIRECT_URL = "{baseUrl}/{action}/oauth2/code/{registrationId}";

protected final ClientRegistration.Builder getBuilder(String registrationId, ClientAuthenticationMethod method,
String redirectUri) {
ClientRegistration.Builder builder = ClientRegistration.withRegistrationId(registrationId);
builder.clientAuthenticationMethod(method);
builder.authorizationGrantType(AuthorizationGrantType.AUTHORIZATION_CODE);
builder.redirectUri(redirectUri);
return builder;
}

/**
* Create a new
* {@link org.springframework.security.oauth2.client.registration.ClientRegistration.Builder
* ClientRegistration.Builder} pre-configured with provider defaults.
* @param registrationId the registration-id used with the new builder
* @return a builder instance
*/
public abstract ClientRegistration.Builder getBuilder(String registrationId);

}
文章作者: Kaka Wan Yifan
文章链接: https://kakawanyifan.com/10827
版权声明: 本博客所有文章版权为文章作者所有,未经书面许可,任何机构和个人不得以任何形式转载、摘编或复制。

留言板