在这一节中我们将学习如何创建一个 springboot 应用,并且支持 基于 JWT 的token认证,将会学习到:
- 基于 JWT 认证的用户登录和注册流程
- spring boot + spring security 的架构
- 如何配置 spring security 支持 JWT
- 如何定义数据模型,并且协助认证和授权
使用 spring data jpa 的方式跟 mysql 数据库进行交互
概览
我们将会创建一个 Spring boot 的应用:
用户可以注册新的账户,使用用户名密码进行登录
- 基于用户的角色,授权给用户可以访问的资源
将提供以下 API:
Methods | Urls | Actions |
---|---|---|
POST | /api/auth/signup | 注册 |
POST | /api/auth/signin | 登录 |
GET | /api/test/all | 公开的API |
GET | /api/test/user | 登录用户可访问的API |
GET | /api/test/mod | 基于角色可访问的API |
GET | /api/test/admin | 管理员可访问的API |
认证流程
下面的图示展示了用户如何进行注册和登录
如果客户端请求访问受保护的资源时,需要在 Header 头上带有一个合法的 JWT
SpringBoot & Spring security 架构
下面的架构有三层,分别是:
- http
- spring-security
- API
结合上图,我们通过spring security 将这些模块轻松的组合在了一起。
首先 HTTP Request 进来, 会被拦截转发到 JwtAuthTokenFilter
, 然后检查所带的请求有没有 token,如果没有则返回认证失败 -> Authentication EntryPoint
如果用户的请求是关于登录和注册, 直接放行,将用户传进来的 username 和 password 传递给 AuthenticationManager
进行认证 。 为此 AuthenticationManager
要实现 configure(AuthenticationManagerBuilder auth)
方法和 实例化 authenticationManagerBean()
并将认证信息生成 JWT token 进行返回
如果用户的请求不是关于登录和请求,那么将会被拦截,进入 AuthTokenFilter 类。 解析和验证token,最后将认证信息存入 SecurityContext 中
实现过程
创建数据库
create database `demo` default character set utf8 collate utf8_general_ci;
创建数据库 ORM 对象
我们一共有三个对象要创建,分别是:
- User : 用户对象
- Role: 角色对象
- ERole: 角色枚举对象
创建 User 对象
package com.example.springbootsecurityjpa.models;
import lombok.Getter;
import lombok.Setter;
import org.springframework.stereotype.Component;
import javax.persistence.*;
import javax.validation.constraints.Email;
import javax.validation.constraints.NotBlank;
import javax.validation.constraints.Size;
import java.util.HashSet;
import java.util.Set;
/**
* @DATA: 2020/12/3
*/
@Setter
@Getter
@Entity
@Table( name = "users",
uniqueConstraints = {
@UniqueConstraint(columnNames = "username"),
@UniqueConstraint(columnNames = "email")
})
public class User {
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
private Long id;
@NotBlank
@Size(max = 20)
private String username;
@NotBlank
@Size(max = 50)
private String email;
@NotBlank
@Size(max = 120)
private String password;
@ManyToMany(fetch = FetchType.LAZY)
@JoinTable( name = "user_roles",
joinColumns = @JoinColumn(name = "user_id"),
inverseJoinColumns = @JoinColumn(name = "role_id"))
private Set<Role> roles = new HashSet<>();
public User() {
}
public User(String username, String email, String password) {
this.username = username;
this.email = email;
this.password = password;
}
}
创建 Role 对象
@Setter
@Getter
@Entity
@Table(name = "roles")
public class Role {
public Role(){}
public Role(Long id, @NotBlank @Size(max = 20) ERole name) {
this.id = id;
this.name = name;
}
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
private Long id;
@NotBlank
@Size(max = 20)
@Enumerated(EnumType.STRING)
private ERole name;
}
这里一个比较大的区别就是,我们在 Role 里面的字段 name 是一个枚举对象。但是写入数据库的时候我们表示成字符串的形式。这里似乎用 @Enumerated 进行标注。
创建 ERole 枚举对象
public enum ERole {
ROLE_USER,
ROLE_MODERATOR,
ROLE_ADMIN;
}
创建数据库操作对象 Repository
Jpa 支持命名查询,可以实现简单的增删改查,我们新建一个 UserRepository
public interface UserRepository extends JpaRepository<User,Long> {
Optional<User> findByUsername(String username);
Boolean existsByUsername(String username);
Boolean existsByEmail(String email);
}
创建 UserDetails 和 UserDetailsService
UserDetails
UserDetails
是 spring-security 中重要的一个概念,它包含了用户用来认证的主要信息。 一般我们创建了 User 类以后,要专门创建这么一个类来执行认证相关的操作。
package com.example.springbootsecurityjpa.services.impl;
import com.example.springbootsecurityjpa.models.User;
import com.fasterxml.jackson.annotation.JsonIgnore;
import com.fasterxml.jackson.annotation.JsonProperty;
import org.springframework.security.core.GrantedAuthority;
import org.springframework.security.core.authority.SimpleGrantedAuthority;
import org.springframework.security.core.userdetails.UserDetails;
import java.util.Collection;
import java.util.List;
import java.util.stream.Collectors;
/**
* @DATA: 2020/12/4
*/
public class UserDetailsImpl implements UserDetails {
private static final long serialVersionUID = 1L;
private Long id;
private String username;
private String email;
@JsonProperty(access = JsonProperty.Access.WRITE_ONLY)
private String password;
private Collection<? extends GrantedAuthority> authorities;
public UserDetailsImpl(Long id, String username, String email, String password, Collection<? extends GrantedAuthority> authorities) {
this.id = id;
this.username = username;
this.email = email;
this.password = password;
this.authorities = authorities;
}
public static UserDetailsImpl build(User user){
List<GrantedAuthority> authorities = user.getRoles().stream()
.map( role -> new SimpleGrantedAuthority(role.getName()))
.collect(Collectors.toList());
return new UserDetailsImpl(
user.getId(),
user.getUsername(),
user.getEmail(),
user.getPassword(),
authorities
);
}
@Override
public Collection<? extends GrantedAuthority> getAuthorities() {
return authorities;
}
@Override
public String getPassword() {
return password;
}
@Override
public String getUsername() {
return username;
}
@Override
public boolean isAccountNonExpired() {
return true;
}
@Override
public boolean isAccountNonLocked() {
return true;
}
@Override
public boolean isCredentialsNonExpired() {
return true;
}
@Override
public boolean isEnabled() {
return true;
}
public boolean equals(Object o) {
if (this == o)
return true;
if (o == null || getClass() != o.getClass())
return false;
UserDetailsImpl user = (UserDetailsImpl) o;
return Objects.equals(id, user.id);
}
}
UserDetailsService
这个类只有一个方法 loadUserByName
@Service
public class UserDetailsServiceIml implements UserDetailsService {
@Autowired
UserRepository userRepository;
@Override
@Transactional // 数据库事务安全。 如果出现异常不会执行任何数据库操作
public UserDetails loadUserByUsername(String username) {
User user = userRepository.findByUsername(username).orElseThrow(
() -> new UsernameNotFoundException("user not found")
);
return UserDetailsImpl.build(user);
}
}
这里需要注意的是,这个类被标注了 @Service
注解,说明这是一个组件,会被实例化。
其中方法中标注了 @Transactional
表示这是一个数据库事务注解,如果发生异常,就不会执行任何的数据库操作
创建 JWT 工具类
我们需要封装一个工具类来 生成/解析/验证 JWT token。 这部分又会涉及到两个内容:
- 读取配置
- jjwt 的用法
读取配置
我们首先在配置文件里面添加关于 JWT 的配置信息
jwt:
secret: SecretKey
expirationMs: 86400000
配置好了,我们需要定义一个类,来加载配置信息
@Configuration
@Setter
@Getter
@ConfigurationProperties(prefix = "jwt")
public class JwtValues {
private String secret;
private long expirationMs;
}
然后我们写一个测试,看看能不能获取到配置内容
@RunWith(SpringRunner.class)
@SpringBootTest
public class JwtValuesTest {
@Autowired
JwtValues jwtValues;
@Test
public void loadConfigurationTest(){
assertEquals("SecretKey", jwtValues.getSecret());
assertTrue(86400000 == jwtValues.getExpirationMs());
}
}
定义 JWT 工具类
JWT 工具类一共有三个功能:
- 从用户名,生效日期,失效日期,secret key 四个关键词中生成 JWT
- 从 JWT token 中获取用户名
- 验证 JWT token ```java
@Component // 需要定义成组件 @Slf4j // 引入 log public class JwtUtil { @Autowired JwtValues jwtValues;
SecretKey key = Keys.hmacShaKeyFor(
jwtValues.getSecret().getBytes(StandardCharsets.UTF_8));
// 生成token
public String generate(Authentication authentication){
UserDetailsImpl userDetails = (UserDetailsImpl) authentication.getPrincipal();
return Jwts.builder()
.setSubject(userDetails.getUsername())
.setIssuedAt(new Date())
.setExpiration(new Date( (new Date()).getTime() + jwtValues.getExpirationMs()))
.signWith(key)
.compact();
}
// 从 token 中得到用户名
public String getUserFromToken(String token){
String name = Jwts.parserBuilder()
.setSigningKey(key)
.build()
.parseClaimsJws(token)
.getBody()
.getSubject();
return name;
}
// 验证 token
public Boolean validate(String token){
try{
Jwts.parserBuilder().setSigningKey(key).build().parseClaimsJws(token);
return true;
}catch (SignatureException e){
log.error("Invalid JWT signature: {}", e.getMessage());
}catch (MalformedJwtException e){
log.error("Invalid JWT token: {}", e.getMessage());
}catch (ExpiredJwtException e){
log.error("JWT token expired: {}" , e.getMessage());
}catch (UnsupportedJwtException e){
log.error("Jwt Token is not supported: {}", e.getMessage());
}catch (IllegalArgumentException e){
log.error("Jwt claims string is empty: {}", e.getMessage());
}catch (Exception e){
log.error("Unexpected error happen: {}",e.getMessage());
}
return false;
}
}
<a name="h7W4B"></a>
## 配置 spring security
<a name="PZ8Bl"></a>
### 配置认证失败
在解析 JWT token 进行认证的时候,总会出现比如 token 过期,token 失效等异常。那么这个时候就需要处理抛出的异常,为此,我们需要配置一个认证失败要如何处理的类
```java
@Component
@Slf4j
public class AuthEntryPointJwt implements AuthenticationEntryPoint {
@Override
public void commence(HttpServletRequest httpServletRequest, HttpServletResponse httpServletResponse, AuthenticationException e) throws IOException, ServletException {
log.error("Unauthorized error:{}" , e.getMessage());
httpServletResponse.sendError(HttpServletResponse.SC_UNAUTHORIZED, "Error: Unauthorized");
}
}
配置 Filter
当用户请求进来时,我们想要将这个请求转发到 spring security 进行检查。其中的逻辑有:
- 从 Auhtorization header 中获取 Token
- 如果有 token, 解析获取 username
- 已知 username 那么我们就可以得到 Userdetails,然后创建对象 Authentication Object
- 然后将对象放进应用上下文,完成认证
然后每一次,你需要获取对象的时候,你可以使用
UserDetails userDetails =
(UserDetails) SecurityContextHolder.getContext().getAuthentication().getPrincipal();
接下来我们完成这个逻辑
@Slf4j
public class AuthTokenFilter extends OncePerRequestFilter {
@Autowired
JwtUtil jwtUtil;
@Autowired
UserDetailsService userDetailsService;
@Override
protected void doFilterInternal(HttpServletRequest httpServletRequest, HttpServletResponse httpServletResponse, FilterChain filterChain) throws ServletException, IOException {
try{
String token = parse(httpServletRequest);
if(token != null && jwtUtil.validate(token)) {
String username = jwtUtil.getUserFromToken(token);
UserDetails userDetails = userDetailsService.loadUserByUsername(username);
UsernamePasswordAuthenticationToken authenticationToken = new UsernamePasswordAuthenticationToken(
userDetails.getUsername(),
null,
userDetails.getAuthorities()
);
authenticationToken.setDetails(new WebAuthenticationDetailsSource().buildDetails(httpServletRequest));
SecurityContextHolder.getContext().setAuthentication(authenticationToken);
}
}catch (Exception e){
log.error("cannot set authentication :{}",e.getMessage());
}
filterChain.doFilter(httpServletRequest,httpServletResponse);
}
private static String parse(HttpServletRequest httpServletRequest){
String headers = httpServletRequest.getHeader("Authorization");
if(StringUtils.hasText(headers) && headers.startsWith("Bearer")){
String token = headers.substring(7, headers.length());
return token;
}
return null;
}
}
配置流程
最后我们需要配置一下 spring security 的流程,把这一切组件串起来。 因为我们的请求进来是通过 spring security来处理,所以我们要在配置里面声明,要把这个请求交给哪个类进行处理,处理完下一步是什么。
@SpringBootConfiguration
@EnableWebSecurity
@EnableGlobalMethodSecurity(prePostEnabled = true)
public class SecurityConfig extends WebSecurityConfigurerAdapter {
@Autowired
AuthEntryPointJwt authEntryPointJwt;
@Autowired
UserDetailsService userDetailsService;
@Bean
public PasswordEncoder createPassworEncoder(){
return PasswordEncoderFactories.createDelegatingPasswordEncoder();
}
// Filter 只能通过第三方 Bean 注入,而不能通过 @Component 等注解进行注入
@Bean
public AuthTokenFilter creatAuthTokenFilter(){
return new AuthTokenFilter();
}
@Override
protected void configure(AuthenticationManagerBuilder auth) throws Exception {
auth.userDetailsService(userDetailsService).passwordEncoder(createPassworEncoder());
}
// 之后我们要使用这个组件来进行认证
@Bean
@Override
public AuthenticationManager authenticationManagerBean() throws Exception {
return super.authenticationManagerBean();
}
@Override
protected void configure(HttpSecurity http) throws Exception {
http.cors().and().csrf().disable()
.exceptionHandling().authenticationEntryPoint(authEntryPointJwt)
.and()
.sessionManagement().sessionCreationPolicy(SessionCreationPolicy.STATELESS)
.and()
.authorizeRequests().mvcMatchers("/api/auth/**")
.permitAll()
.anyRequest().permitAll();
http.addFilterBefore(creatAuthTokenFilter(), UsernamePasswordAuthenticationFilter.class);
}
}
我们稍微解释一下上面的代码是什么意思:
- @EnableSecurity: 允许 Spring 寻找和应用这个类到全局的 Web 安全上
- @EnableGlobalMethodSecurity : 提供了 AOP形式的安全检查到方法上,这里主要是为了 @PreAuthorize 和 @PostAuthorize 两个方法。 它同时也支持 JSR-250. 在 这里 可以发现更多的安全方法
- 我们实例化了
PasswordEncode
如果没有实例化,将会使用明文密码 - 我们实例化了
AuthTokenFilter
,注入到配置里,用来拦截请求 - 定义了
configure(AuthenticationManagerBuilder auth)
用来说明是如何实现认证的 - 实例化了
AuthenticationManager
, 之后就可以通过AuthenticationManager.authenticate(username,password)
来调用上面的configure(AuthenticationManagerBuilder auth)
方法进行登录验证 - 定义了
configure(HttpSecurity http)
这个是最关键的,它定义了如何去配置 CORS和 CSRF,并且请求进来以后,要怎么工作,以及发生异常后的处理
配置 controller
Controller 分为两部分,一部分是 Auth ,一部分是Test
auth
@RestController
@RequestMapping("/api/auth")
public class AuthController {
@Autowired
UserService userService;
@PostMapping("/signup")
public ResponseEntity<?> signup(@Valid @RequestBody RegisterRequest registerRequest){
return userService.register(registerRequest);
}
@PostMapping("/signin")
public ResponseEntity<?> signin(@Valid @RequestBody SigninRequest signinRequest){
return userService.login(signinRequest);
}
}
业务逻辑写在了 service 里
@Service
@Slf4j
public class UserServiceImpl implements UserService {
@Autowired
UserRepository userRepository;
@Autowired
RoleRepository roleRepository;
@Autowired
PasswordEncoder passwordEncoder;
@Autowired
AuthenticationManager authenticationManager;
@Autowired
JwtUtil jwtUtil;
@Override
@Transactional
public ResponseEntity<?> register(RegisterRequest registerRequest) {
String username = registerRequest.getName();
String email = registerRequest.getEmail();
String password = passwordEncoder.encode(registerRequest.getPassword());
if(userRepository.existsByUsername(username)){
return ResponseEntity.badRequest().body(
new GeneralResponse<>("username exists"));
}
if(userRepository.existsByEmail(email)){
return ResponseEntity.badRequest().body(
new GeneralResponse<>("email exists"));
}
User user = new User(username,email,password);
Set<String> strRoles = registerRequest.getRole();
Set<Role> roles = new HashSet<>();
if(strRoles == null){
Role role = roleRepository.findByName(ERole.ROLE_USER).orElseThrow(()->new RuntimeException("Error: role is not exists"));
roles.add(role);
}else{
strRoles.forEach(
roleName -> {
log.info(roleName);
switch (roleName) {
case "admin":
Role adminRole = roleRepository.findByName(ERole.ROLE_ADMIN)
.orElseThrow(() -> new RuntimeException("admin role is not exists"));
roles.add(adminRole);
break;
case "mod":
Role modRole = roleRepository.findByName(ERole.ROLE_MODERATOR)
.orElseThrow(()->new RuntimeException("mod role is not exists"));
roles.add(modRole);
break;
default:
Role userRole = roleRepository.findByName(ERole.ROLE_USER)
.orElseThrow(()-> new RuntimeException("user role is not exists"));
roles.add(userRole);
}
}
);
}
user.setRoles(roles);
userRepository.save(user);
return ResponseEntity.ok(
new GeneralResponse<>("Register successful!")
);
}
@Override
public ResponseEntity<?> login(SigninRequest signinRequest) {
String username = signinRequest.getUsername();
String password = signinRequest.getPassword();
Authentication authentication = authenticationManager.authenticate(new UsernamePasswordAuthenticationToken(username,password));
SecurityContextHolder.getContext().setAuthentication(authentication);
String jwt = jwtUtil.generate(authentication);
UserDetailsImpl userDetails = (UserDetailsImpl) authentication.getPrincipal();
List<String> roles = userDetails.getAuthorities().stream()
.map(item -> ((GrantedAuthority) item).getAuthority())
.collect(Collectors.toList());
return ResponseEntity.ok(new JwtResponse(jwt,
userDetails.getId(),
userDetails.getUsername(),
userDetails.getEmail(),
roles
));
}
}
总结
我们通过 spring boot + spring-security + jwt + jpa 的技术栈实现了一个使用 JWT 认证的应用。 有以下功能
- 注册
- 登录
- 授权访问
这个项目还存在很多的缺点,比如 token 的刷新问题,token 的缓存(性能问题,每次解析 token 获取用户都需要去数据库查询,会出现性能问题),权限没有更加细致化等等。
代码地址:
https://github.com/codemak2r/SpringBootExample/tree/master/spring-boot-security-jpa