SpringBoot+Security+JWT基础

First

  • 我第一次使用,所以代码中的注释和说明比较多

优点

  • 由于服务器不保存 session 状态,因此无法在使用过程中废止某个 token,或者更改 token 的权限。也就是说,一旦 JWT 签发了,在到期之前就会始终有效,除非服务器部署额外的逻辑。

缺点

Security 基本原理

下面每个类或接口的作用,之后都会有代码.

了解 Token结构

Token是一个很长的字符串,中间用点(.)分隔成三个部分。

JWT 的三个部分依次如下。

  • Header(头部)
  • Payload(负载)
  • Signature(签名)

Header(头部)

1
2
3
4
{
"alg": "HS256",
"typ": "JWT"
}

alg属性表示签名的算法(algorithm),默认是 HMAC SHA256(写成 HS256);typ属性表示这个令牌(token)的类型(type),JWT 令牌统一写为JWT

Payload(负载)

Payload 部分也是一个 JSON 对象,用来存放实际需要传递的数据。JWT 规定了7个官方字段,供选用。

  • iss (issuer):签发人
  • exp (expiration time):过期时间
  • sub (subject):主题
  • aud (audience):受众
  • nbf (Not Before):生效时间
  • iat (Issued At):签发时间
  • jti (JWT ID):编号

除了官方字段,你还可以在这个部分定义私有字段,下面就是一个例子。

1
2
3
4
5
{
"sub": "1234567890",
"name": "John Doe",
"admin": true
}

注意,JWT 默认是不加密的,任何人都可以读到,所以不要把秘密信息放在这个部分。

Signature(签名)

Signature 部分是对前两部分的签名,防止数据篡改。

首先,需要指定一个密钥(secret)。这个密钥只有服务器才知道,不能泄露给用户。然后,使用 Header 里面指定的签名算法(默认是 HMAC SHA256),按照下面的公式产生签名。

1
2
3
4
HMACSHA256(
base64UrlEncode(header) + "." +
base64UrlEncode(payload),
secret)

算出签名以后,把 Header、Payload、Signature 三个部分拼成一个字符串,每个部分之间用”点”(.)分隔,就可以返回给用户。

思路

  1. 构建
  2. 导入 security 、 jwt 依赖
  3. 用户的验证(service 、 dao 、model)
  4. 实现UserDetailsServiceUserDetails接口
  5. 可选:实现PasswordEncoder 接口(密码加密)
  6. 验证用户登录信息、用户权限的拦截器
  7. security 配置
  8. 登录认证 API

构建

1. 构建

创建个项目

2.导入 security 、 jwt 依赖

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

<project>
<spring-security-jwt.version>1.0.9.RELEASE</spring-security-jwt.version>
<jjwt.version>0.9.1</jjwt.version>
<dependency>
<groupId>io.jsonwebtoken</groupId>
<artifactId>jjwt</artifactId>
<version>${jjwt.version}</version>
</dependency>
<dependency>
<groupId>org.springframework.security</groupId>
<artifactId>spring-security-jwt</artifactId>
<version>${spring-security-jwt.version}</version>
<scope>compile</scope>
</dependency>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-data-jpa</artifactId>
</dependency>
<dependency>
<groupId>mysql</groupId>
<artifactId>mysql-connector-java</artifactId>
<scope>runtime</scope>
</dependency>
<dependency>
<groupId>com.alibaba</groupId>
<artifactId>druid-spring-boot-starter</artifactId>
<version>1.1.16</version>
</dependency>
<!--使用啦Lombok插件,需要自己添加 其它需要自己添加了-->
</project>

3.用户的验证(service 、 dao 、model)

model

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
/**
* 用户表
*
*
* @author maxzhao
* @date 2019-6-6 13:53:17
*/
@Accessors(chain = true)
@Data
@Entity
@Table(name = "app_user", schema = "", catalog = "")
@ApiModel(value = "用户表", description = "用户表")
public class AppUser implements Serializable {
private static final long serialVersionUID = -1L;

@Id
@Column(name = "ID",unique = true)
private Long id;

@Basic
@Column(name = "LIVE_ADDRESS")
private String liveAddress;

@Basic
@Column(name = "LOGIN_NAME")
private String loginName;
@Basic
@Column(name = "PASSWORD")
private String password;
/* 省略 其它*/
}

dao

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
/**
* 用户表
* Repository
*
* @author maxzhao
* @date 2019-5-21 11:17:39
*/
@Repository(value = "appUserRepository")
public interface AppUserRepository extends JpaRepository<AppUser, Long>, JpaSpecificationExecutor<AppUser> {
/**
* 根据登录名 查询当前用户
*
* @param loginName 登录名
* @return
* @author maxzhao
* @date 2019-05-22
*/
List<AppUser> findByLoginNameEquals(String loginName);

}

service

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

/**
* 用户表
* Service
*
* @author maxzhao
* @date 2019-5-21 11:17:39
*/
public interface AppUserService {
/**
* 保存
*
* @param appUser
* @return
* @author maxzhao
* @date 2019-06-19
*/
AppUser saveOne(AppUser appUser);

/**
* 根据登录名查询 当前登录用户
*
* @param loginName
* @return
*/
AppUser findByLoginName(String loginName);
}
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
/**
* 用户表
* ServiceImpl
*
* @author maxzhao
* @date 2019-5-21 11:17:39
*/
@Service(value = "appUserService")
public class AppUserServiceImpl implements AppUserService {

@Resource(name = "appUserRepository")
private AppUserRepository appUserRepository;

@Override
public AppUser saveOne(AppUser appUser) {
return appUserRepository.save(appUser);
}

@Override
public AppUser findByLoginName(String loginName) {
List<AppUser> appUserList = appUserRepository.findByLoginNameEquals(loginName);
return appUserList.size() > 0 ? appUserList.get(0) : null;
}
}

Jwt 工具类

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
/**
* <p>jjwt封装一下方便调用</p>
* <p>JwtTokenUtil</p>
*
* @author maxzhao
* @date 2019-07-04 13:30
*/
public class JwtTokenUtil {

/**
* 密钥
*/
private static final String SECRET = "jwt_secret_gtboot";
private static final String ISS = "gtboot";

/**
* 过期时间是 1800 秒
*/
private static final long EXPIRATION = 1800L;

public static String createToken(String issuer, String subject, long expiration) {
return createToken(issuer, subject, expiration, null);
}

/**
* 创建 token
*
* @param issuer 签发人
* @param subject 主体,即用户信息的JSON
* @param expiration 有效时间(秒)
* @param claims 自定义参数
* @return
* @description todo https://www.cnblogs.com/wangshouchang/p/9551748.html
*/
public static String createToken(String issuer, String subject, long expiration, Claims claims) {
return Jwts.builder()
// JWT_ID:是JWT的唯一标识,根据业务需要,这个可以设置为一个不重复的值,主要用来作为一次性token,从而回避重放攻击。
// .setId(id)
// 签名算法以及密匙
.signWith(SignatureAlgorithm.HS512, SECRET)
// 自定义属性
.setClaims(null)
// 主题:代表这个JWT的主体,即它的所有人,这个是一个json格式的字符串,可以存放什么userid,roldid之类的,作为什么用户的唯一标志。
.setSubject(subject)
// 受众
// .setAudience(loginName)
// 签发人
.setIssuer(Optional.ofNullable(issuer).orElse(ISS))
// 签发时间
.setIssuedAt(new Date())
// 过期时间
.setExpiration(new Date(System.currentTimeMillis() + (expiration > 0 ? expiration : EXPIRATION) * 1000))
.compact();
}

/**
* 从 token 中获取主题信息
*
* @param token
* @return
*/
public static String getProperties(String token) {
return getTokenBody(token).getSubject();
}

/**
* 校验是否过期
*
* @param token
* @return
*/
public static boolean isExpiration(String token) {
return getTokenBody(token).getExpiration().before(new Date());
}

/**
* 获得 token 的 body
*
* @param token
* @return
*/
private static Claims getTokenBody(String token) {
return Jwts.parser()
.setSigningKey(SECRET)
.parseClaimsJws(token)
.getBody();
}
}

4.实现UserDetailsServiceUserDetails接口

UserDetailsService

可以把角色相关禁用掉,然后修改参数.

我也会把角色相关操作,以及表的 sql 放到最后.

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
/**
* 加载特定于用户的数据的核心接口。
* 它作为用户DAO在整个框架中使用,是DaoAuthenticationProvider使用的策略。
* 该接口只需要一个只读方法,这简化了对新数据访问策略的支持。
*
* @author maxzhao
*/
@Service("userDetailsService")
public class UserDetailsServiceImpl implements UserDetailsService {
private static final Logger logger = LoggerFactory.getLogger(UserDetailsServiceImpl.class);
/**
* 用户操作服务
*/
@Resource(name = "appUserService")
private AppUserService appUserService;

/**
* 用户角色服务
*/
@Resource(name = "appRoleService")
private AppRoleService appRoleService;

/**
* 根据用户登录名定位用户。
*
* @param loginName
* @return
* @throws UsernameNotFoundException
*/
@Override
public UserDetails loadUserByUsername(String loginName) throws UsernameNotFoundException {

UserDetails userDetails = null;
try {
AppUser appUser = appUserService.findByLoginName(loginName);
if (appUser != null) {
// 查询当前用户的权限
List<AppRole> appRoleList = appRoleService.findByUserId(appUser.getId());
Collection<GrantedAuthority> authorities = new ArrayList<>();
for (AppRole appRole : appRoleList) {
SimpleGrantedAuthority grant = new SimpleGrantedAuthority(appRole.getConstName());
authorities.add(grant);
}
//封装自定义UserDetails类
userDetails = new UserDetailsImpl(appUser, authorities);
} else {
throw new UsernameNotFoundException("该用户不存在!");
}
} catch (Exception e) {
logger.error(e.getMessage());
}
return userDetails;
}

}

UserDetails

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
/**
* 自定义用户身份信息
* 提供核心用户信息。
* 出于安全目的,Spring Security不直接使用实现。它们只是存储用户信息,这些信息稍后封装到身份验证对象中。这允许将非安全相关的用户信息(如电子邮件地址、电话号码等)存储在一个方便的位置。
* 具体实现必须特别注意,以确保每个方法的非空契约都得到了执行。有关参考实现(您可能希望在代码中对其进行扩展或使用),请参见User。
*
* @author maxzhao
* @date 2019-05-22
*/
public class UserDetailsImpl implements UserDetails {
private static final long serialVersionUID = 1L;
/**
* 用户信息
*/
private AppUser appUser;
/**
* 用户角色
*/
private Collection<? extends GrantedAuthority> authorities;

public UserDetailsImpl(AppUser appUser, Collection<? extends GrantedAuthority> authorities) {
super();
this.appUser = appUser;
this.authorities = authorities;
}

/**
* 返回用户所有角色的封装,一个Role对应一个GrantedAuthority
*
* @return 返回授予用户的权限。
*/
@Override
public Collection<? extends GrantedAuthority> getAuthorities() {
/* Collection<GrantedAuthority> authorities = new ArrayList<>();
String username = this.getUsername();
if (username != null) {
SimpleGrantedAuthority authority = new SimpleGrantedAuthority(username);
authorities.add(authority);
}*/
return authorities;
}
/**
* 返回用于验证用户身份的密码。
*
* @return Returns the password used to authenticate the user.
*/
@Override
public String getPassword() {
return appUser.getPassword();
}
/**
* @return
*/
@Override
public String getUsername() {
return appUser.getLoginName();
}
/**
* 判断账号是否已经过期,默认没有过期
*
* @return true 没有过期
*/
@Override
public boolean isAccountNonExpired() {
return appUser.getExpiration() == null || appUser.getExpiration().before(new Date());
}
/**
* 判断账号是否被锁定,默认没有锁定
*
* @return true 没有锁定 false 锁定
*/
@Override
public boolean isAccountNonLocked() {
return appUser.getLockStatus() == null || appUser.getLockStatus() == 0;
}
/**
* todo 判断信用凭证是否过期,默认没有过期
*
* @return
*/
@Override
public boolean isCredentialsNonExpired() {
return true;
}
/**
* 判断账号是否可用,默认可用
*
* @return
*/
@Override
public boolean isEnabled() {
return appUser.getDelStatus() == 0;
}
}

5.可选:实现PasswordEncoder 接口(密码加密)

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
/**
* PasswordEncoderImpl
*
* @author maxzhao
* @date 2019-05-23 15:55
*/
@Service("passwordEncoder")
public class PasswordEncoderImpl implements PasswordEncoder {
private final int strength;
private final SecureRandom random;
private Pattern BCRYPT_PATTERN;
private Logger logger;

/**
* 构造函数用于设置不同的加密过程
*/
public PasswordEncoderImpl() {
this(-1);
}

public PasswordEncoderImpl(int strength) {
this(strength, null);
}

public PasswordEncoderImpl(int strength, SecureRandom random) {
this.BCRYPT_PATTERN = Pattern.compile("\\A\\$2a?\\$\\d\\d\\$[./0-9A-Za-z]{53}");
this.logger = LoggerFactory.getLogger(this.getClass());
if (strength == -1 || strength >= 4 && strength <= 31) {
this.strength = strength;
this.random = random;
} else {
throw new IllegalArgumentException("Bad strength");
}
}

/**
* 对原始密码进行编码。通常,一个好的编码算法应用SHA-1或更大的哈希值和一个8字节或更大的随机生成的salt。
* Encode the raw password. Generally, a good encoding algorithm applies a SHA-1 or greater hash combined with an 8-byte or greater randomly generated salt.
*
* @param rawPassword
* @return
*/
@Override
public String encode(CharSequence rawPassword) {
String salt;
if (this.strength > 0) {
if (this.random != null) {
salt = BCrypt.gensalt(this.strength, this.random);
} else {
salt = BCrypt.gensalt(this.strength);
}
} else {
salt = BCrypt.gensalt();
}

return BCrypt.hashpw(rawPassword.toString(), salt);
}

/**
* 验证从存储中获得的已编码密码在经过编码后是否与提交的原始密码匹配。
* 如果密码匹配,返回true;如果密码不匹配,返回false。存储的密码本身永远不会被解码。
*
* @param rawPassword the raw password to encode and match
* @param encodedPassword the encoded password from storage to compare with
* @return
*/
@Override
public boolean matches(CharSequence rawPassword, String encodedPassword) {
if (encodedPassword != null && encodedPassword.length() != 0) {
if (!this.BCRYPT_PATTERN.matcher(encodedPassword).matches()) {
this.logger.warn("Encoded password does not look like BCrypt");
return false;
} else {
return BCrypt.checkpw(rawPassword.toString(), encodedPassword);
}
} else {
this.logger.warn("Empty encoded password");
return false;
}
}

/**
* 如果为了更好的安全性,应该再次对已编码的密码进行编码,则返回true,否则为false。
*
* @param encodedPassword the encoded password to check
* @return Returns true if the encoded password should be encoded again for better security, else false. The default implementation always returns false.
*/
@Override
public boolean upgradeEncoding(String encodedPassword) {
return false;
}
}

6.验证用户登录信息、用户权限的拦截器

  • JwtAuthenticationFilter用户账号的验证
  • JwtAuthorizationFilter用户权限的验证

JwtAuthenticationFilter继承于UsernamePasswordAuthenticationFilter
该拦截器用于获取用户登录的信息,只需创建一个 token并调用 authenticationManager.authenticate() spring-security
去进行验证就可以了,不用自己查数据库再对比密码了,这一步交给spring去操作。

这个操作有点像是shirosubject.login(new UsernamePasswordToken()),验证的事情交给框架。

7.security 配置

8.登录认证 API

配置文件

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
spring:
datasource:
driverClassName: com.mysql.cj.jdbc.Driver
url: jdbc:mysql://127.0.0.1:3306/maxzhao_ittest?charset=utf8mb4&useSSL=false
username: maxzhao
password: maxzhao
main:
allow-bean-definition-overriding: true

jpa:
database: MYSQL
database-plinatform: org.hibernate.dialect.MySQL5InnoDBDialect
show-sql: true
generate-ddl: true
open-in-view: false

hibernate:
ddl-auto: update
# naming-strategy: org.hibernate.cfg.ImprovedNamingStrategy
properties:
#不加此配置,获取不到当前currentsession
hibernate:
current_session_context_class: org.springframework.orm.hibernate5.SpringSessionContext
dialect: org.hibernate.dialect.MySQL5Dialect
# 多数据源配置
gt:
maxzhao:
boot:
#主动开启多数据源
multiDatasourceOpen: true
datasource[0]:
dbName: second
driverClassName: com.mysql.cj.jdbc.Driver
url: jdbc:mysql://127.0.0.1:3306/pos?charset=utf8mb4&useSSL=false
username: maxzhao
password: maxzhao
datasource[1]:
dbName: third
driverClassName: com.mysql.cj.jdbc.Driver
url: jdbc:mysql://127.0.0.1:3306/biz?charset=utf8mb4&useSSL=false
username: maxzhao
password: maxzhao

本文地址: SpringBoot+Security+JWT基础

gitee

推荐
SpringBoot+Security+JWT基础
SpringBoot+Security+JWT进阶:一、自定义认证
SpringBoot+Security+JWT进阶:二、自定义认证实践

本文地址: https://github.com/maxzhao-it/blog/post/59134/