面向Java开发者的全能型Spring安全速成班
每当我们构建一个应用程序时,安全可能不是我们首先考虑的事情。然而,它却是需要我们关注的最重要的功能之一。在这篇文章中,我们将介绍用Spring Security保护你的应用程序的一些关键方法。安全可能看起来有点令人生畏,尤其是当你专注于大局时。
前提条件
在本指南中,我们将假设你对Spring Boot有一些经验。这包括一些常见的设计模式,如DTO和服务。我们还将假设你有一些Spring数据的基本知识,可以在数据库中存储用户。
理论
我们将涉及的主要功能是认证和授权的不同策略。授权是验证用户做他们所要求做的事情的过程。认证是验证是谁在发送请求的过程。我们将讨论的三种认证策略是HTTP basic、JWT和OAuth。
HTTP基本认证
通过HTTP基本认证,每个安全请求都需要一个授权头。发送到控制器的每个请求都有头文件。这些是一堆键值对,提供关于请求的额外信息。当我说授权头时,这意味着头的键是字符串Authorization。
对于HTTP基本认证,该值必须是这样的格式:Basic <Credentials> 。在这种情况下,凭证是由冒号连接的用户名和密码,并以64进制编码。虽然简单,但这种策略也有一些问题。
Base 64编码很容易被逆转,因此实际上是不受保护的。虽然我们可以用HTTPS连接来弥补,但在每个请求中都附加凭证还是不理想。如果你有一个活动会话,浏览器有时会自动发送授权头。
这可能使你的客户容易受到CSRF(跨站请求伪造)攻击。这些攻击是指另一个网络服务器可以在你的客户端有一个活动会话时发送请求。然而,通过JWT认证,我们可以完全避免这个问题。
JWT认证
JWT认证是一项相当新的技术,但正日益流行。它是一种允许在你的应用程序中进行无状态认证的方法。这意味着你不再需要在会话中存储用户信息来验证每个请求。相反,我们将这些信息与我们想要的任何数据一起与每个请求一起发送。
其工作原理是,当客户端登录时,服务器会给他们一个JSON Web Token(JWT)。之后,客户端必须将该令牌放在每个请求的授权头中,放在Bearer和空格之后。该令牌有三个不同的部分,头、有效载荷和签名。头部是一个JSON字符串,包含关于令牌的元数据。
有效载荷也是一个JSON字符串,但包含请求的信息。有效载荷中的字段被称为索赔。有效载荷必须包含一个被称为主题的索赔,但你也可以把你需要的任何索赔。这个主题要求只是发送请求的用户的一个标识符。头部和有效载荷都是以64进制编码的,但如前所述,任何人都可以轻易地解码它们。因此,你不能把任何敏感信息放在有效载荷中。
使JWT安全的是签名。签名是用被称为秘密的密钥对头和有效载荷进行加密。秘密使得任何不知道密钥的人都无法解密签名。如果有人修改了标题或有效载荷,服务器可以解码签名并看到它被修改了。
这也确保了没有人可以随便制作自己的令牌来试图欺骗系统。授权头也不是由浏览器自动添加到每个请求中的。如前所述,浏览器自动添加头会造成CRSF的漏洞。这样一来,我们就完全避免了CSRF的问题。
OAuth
有了OAuth,认证反而由受信任的第三方处理。这就是当一个网站要求你使用比方说你的谷歌账户而不是该网站的账户来登录。如果你的应用程序使用谷歌的服务,需要用户的谷歌账户,这可能特别有用。
**注意:**你可以结合使用OAuth和JWT认证。第三方可以为你处理所有的账户和初始登录。然后在初始登录后,我们可以给一个JWT令牌来验证进一步的请求,而不使用会话。在本指南中,我们将不涉及这一策略。
用HTTP基本认证实现安全
现在我们介绍了这些认证方法,让我们开始实施它们。我们要做的是设置一个Spring Boot应用程序,使其拥有一个带有编码密码的用户数据库。然后,我们将为每个请求配置HTTP基本认证。
在我们的例子中,我们将确保以下端点的安全。
@Autowired
private UserService userService;
@PostMapping("/api/auth/signup")
public ResponseEntity<Void> signUp(@RequestBody UserDto userDto){
userService.saveUser(userDto);
return ResponseEntity.noContent().build();
}
@GetMapping("/api/hello-world")
public String helloWorld(){
return "Hello World";
}
@GetMapping("/api/secret-admin-business")
public Integer getMeaningOfLife(){
return 42;
}
我们希望我们的注册端点是完全公开的,没有认证。同时,hello world端点应该可以被任何认证的用户访问。最后,/api/secret-admin-business ,应该只有管理员才能访问。
首先,我们从Spring初始化器中创建一个新的Spring Boot应用。我们需要Spring starter安全依赖,Spring web依赖,以及你选择的数据库的依赖。
为了专注于安全方面的事情,我将只简单地描述数据访问层。我也将省略数据库的配置。
在这里,我们将与下面的用户实体一起工作,我们将存储在数据库中。
package me.john.amiscaray.springsecuritydemo.entities;
import me.john.amiscaray.springsecuritydemo.dtos.UserDto;
import javax.persistence.*;
@Entity
public class User {
@Id
@GeneratedValue(strategy = GenerationType.AUTO)
private Long userId;
@Column(nullable = false, length = 50, unique = true)
private String username;
@Column(nullable = false)
private String password;
@Column(nullable = false, length = 10)
private String authority;
private String secret;
public User(UserDto dto){
username = dto.getUsername();
password = dto.getPassword();
authority = "ROLE_USER";
}
// Getters, Setters, Empty constructor below ...
}
我们将使用权限字段,只允许管理员访问/api/secret-admin-business 端点。我们还将为这个实体设置相应的DTO、JpaRespository 和UserService 类。JpaRespository将有一个单一的findUserByUsername 方法被定义。同时,UserService 类将有一个单一的saveUser method 。这将把一个DTO转换为一个用户对象并保存起来。
接下来,我们需要实现UserDetailsService 接口。Spring将使用这个类来访问我们的用户进行认证。你会发现我们必须实现一个方法,loadUserByUsername 。
这个方法返回一个UserDetails 对象并抛出一个UsernameNotFoundException 。然而,UserDetails 是一个接口,我们还没有一个实现。这个接口将作为我们的User 对象的一个封装器。我们将用它来提供Spring安全需要的关于我们用户的额外信息。
让我们首先创建下面这个UserDetails 的实现。
package me.john.amiscaray.springsecuritydemo.entities;
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;
public class AppUserDetails implements UserDetails {
private final User user;
public AppUserDetails(User user){
this.user = user;
}
@Override
public Collection<? extends GrantedAuthority> getAuthorities() {
return List.of(new SimpleGrantedAuthority(user.getAuthority()));
}
@Override
public String getPassword() {
return user.getPassword();
}
@Override
public String getUsername() {
return user.getUsername();
}
@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 User getUser() {
return user;
}
}
为了简单起见,我们让用户的账户始终处于活动状态。这样,我们就可以用账户激活状态来硬编码所有的方法。现在我们有了一个UserDetails 的实现,我们可以完成实现UserDetailService 。
package me.john.amiscaray.springsecuritydemo.services;
import me.john.amiscaray.springsecuritydemo.data.UserRepo;
import me.john.amiscaray.springsecuritydemo.entities.AppUserDetails;
import me.john.amiscaray.springsecuritydemo.entities.User;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.security.core.userdetails.UserDetails;
import org.springframework.security.core.userdetails.UserDetailsService;
import org.springframework.security.core.userdetails.UsernameNotFoundException;
import org.springframework.stereotype.Service;
import java.util.Optional;
@Service
public class AppUserDetailsService implements UserDetailsService {
@Autowired
private UserRepo userRepo;
@Override
public UserDetails loadUserByUsername(String s) throws UsernameNotFoundException {
Optional<User> user = userRepo.findUserByUsername(s);
if(user.isPresent()){
return new AppUserDetails(user.get());
}else{
throw new UsernameNotFoundException("User not found");
}
}
}
之后,我们需要创建一个类来开始配置安全。首先,我们需要创建一个WebSecurityConfigurerAdapter 类的子类。这个超类有我们可以覆盖的方法来配置安全,然后我们必须在这个类中添加@Configuration 和@EnableWebSecurity 注解。
最后,我们添加以下内容。
@Autowired
private AppUserDetailsService userDetailsService;
@Override
protected void configure(HttpSecurity http) throws Exception {
http.csrf().disable()
.authorizeRequests()
.antMatchers("/api/secret-admin-business").hasAnyRole("ADMIN")
.anyRequest().fullyAuthenticated()
.and().httpBasic();
}
@Override
public void configure(WebSecurity web) throws Exception {
web.ignoring()
.antMatchers("/api/auth/signup");
}
@Override
protected void configure(AuthenticationManagerBuilder auth) throws Exception {
auth.userDetailsService(userDetailsService).passwordEncoder(getPasswordEncoder());
}
@Bean
public PasswordEncoder getPasswordEncoder(){
return new BCryptPasswordEncoder(10);
}
在第一个方法中,我们限制/api/secret-admin-business 端点只能由管理员的用户调用。为了让Spring认为用户是管理员,他们的权限字段必须是字符串 "ROLLE_ADMIN"。hasAnyRole 方法将 "ROLLE_"前缀添加到我们作为角色传递的字符串中。然后我们指定每个请求都需要HTTP基本认证。
为了简单起见,我们还禁用了CSRF安全。在下一个方法中,我们让Spring安全系统忽略了注册端点。这样一来,这个端点就不需要我们前面讨论的认证了。注意,我们还配置了密码编码器的使用,并为其创建了一个bean。我们必须确保在我们保存DTO之前,首先用密码编码器的encode 方法对密码进行编码。
就这样,我们已经配置了简单的HTTP基本认证。不仅如此,我们的应用程序也有一些基于角色的授权。尽管基于角色的授权并不是Spring提供的唯一授权方法。Spring安全还允许使用注解进行方法级的授权。
简单的方法级授权
作为一个不切实际的例子,假设我们给User 对象一个新的字段,叫做secret 。这包含了只有秘密的主人才能访问的敏感信息。然后假设我们有一个端点/api/user/{username}/secret ,我们可以发送一个GET请求来检索用户的秘密。
相应的控制器将从UserService 类中调用以下方法。
public String getSecret(String username){
User user = userRepo.findUserByUsername(username).orElseThrow();
return user.getSecret();
}
我们需要保护这个方法,使我们的端点只能用登录用户的用户名来调用它。首先,我们需要创建一个新的类来允许配置方法级授权。
package me.john.amiscaray.springsecuritydemo.config;
import org.springframework.context.annotation.Configuration;
import org.springframework.security.config.annotation.method.configuration.EnableGlobalMethodSecurity;
import org.springframework.security.config.annotation.method.configuration.GlobalMethodSecurityConfiguration;
@Configuration
@EnableGlobalMethodSecurity(
prePostEnabled = true)
public class MethodSecurityConfig
extends GlobalMethodSecurityConfiguration {
}
注意我们如何将prePostEnabled 属性设置为true。这允许我们使用@PreAuthorize 和@PostAuthorize 注解。将这些注解添加到一个方法中,可以让我们检查应用程序是否按照预期使用该方法。
在我们的例子中,我们会将注解添加到我们的getSecret 方法中,如下所示。
@PreAuthorize("#username == authentication.principal.username")
正如该注解的值所表明的,它确保传递的用户名是登录用户的用户名。
实现JWT
现在让我们试着看看如何升级我们的应用程序,以使用基于JWT的认证。不幸的是,配置JWT要比设置HTTP基本认证更复杂。我已经尽可能地创建了一个简单的实现,这样你就可以跟着做了。
我们首先添加以下依赖关系。
<dependency>
<groupId>com.auth0</groupId>
<artifactId>java-jwt</artifactId>
<version>3.10.3</version>
</dependency>
接下来,我们需要创建一个AuthenticationManager 接口的实现,以注入到我们的bean中。这个服务将有一个单一的方法,它将接受一个Authentication 对象。这个对象包含我们需要验证的principal 和credentials 字段。在我们的用例中,我们将用它们分别代表一个用户名和密码。然而,由于它们都是Object ,你可以让它们成为你想要的任何类型。下面是我们的实现。
package me.john.amiscaray.springsecuritydemo.services;
import me.john.amiscaray.springsecuritydemo.data.UserRepo;
import me.john.amiscaray.springsecuritydemo.entities.User;
import me.john.amiscaray.springsecuritydemo.exception.AuthenticationExceptionImpl;
import org.springframework.security.authentication.AuthenticationManager;
import org.springframework.security.core.Authentication;
import org.springframework.security.core.AuthenticationException;
import org.springframework.security.crypto.password.PasswordEncoder;
import org.springframework.stereotype.Service;
@Service
public class AuthenticationManagerImpl implements AuthenticationManager {
private final UserRepo userRepo;
private final PasswordEncoder passwordEncoder;
public AuthenticationManagerImpl(UserRepo userRepo, PasswordEncoder passwordEncoder) {
this.userRepo = userRepo;
this.passwordEncoder = passwordEncoder;
}
@Override
public Authentication authenticate(Authentication authentication) throws AuthenticationException {
/*
We will have the principal and credentials fields be used as usernames and passwords respectively, so we
need to assert that they are indeed Strings. This will throw an Exception if that is not true.
*/
assert (authentication.getPrincipal() instanceof String && authentication.getCredentials() instanceof String);
User user = userRepo.findUserByUsername((String) authentication.getPrincipal()).orElseThrow();
if(passwordEncoder.matches((String) authentication.getCredentials(), user.getPassword())){
return authentication;
}
/*
AuthenticationExceptionImpl is a simple class I defined which extends AuthenticationException (an abstract class).
*/
throw new AuthenticationExceptionImpl("Could not verify user");
}
}
然后,我们创建以下服务类。
package me.john.amiscaray.springsecuritydemo.services;
import com.auth0.jwt.JWT;
import com.auth0.jwt.algorithms.Algorithm;
import me.john.amiscaray.springsecuritydemo.dtos.UserDto;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.security.authentication.AuthenticationManager;
import org.springframework.security.authentication.UsernamePasswordAuthenticationToken;
import org.springframework.security.core.Authentication;
import org.springframework.security.core.AuthenticationException;
import org.springframework.security.core.userdetails.UserDetails;
import org.springframework.stereotype.Service;
import java.util.Date;
@Service
public class JWTAuthService {
private final AuthenticationManager authenticationManager;
private final AppUserDetailsService userDetailsService;
private final String SECRET = "secret";
@Autowired
public JWTAuthService(AuthenticationManager authenticationManager,
AppUserDetailsService userDetailsService){
this.authenticationManager = authenticationManager;
this.userDetailsService = userDetailsService;
}
public String getJWT(UserDto dto){
try {
UserDetails user = userDetailsService.loadUserByUsername(dto.getUsername());
Authentication auth = authenticationManager.authenticate(new UsernamePasswordAuthenticationToken(
dto.getUsername(),
dto.getPassword(),
user.getAuthorities()
));
}catch (AuthenticationException ex){
throw new IllegalArgumentException("User not found");
}
long TEN_HOURS = 36000000L;
return JWT.create()
.withSubject(dto.getUsername())
.withExpiresAt(new Date(System.currentTimeMillis() + TEN_HOURS))
.sign(Algorithm.HMAC512(SECRET.getBytes()));
}
public UsernamePasswordAuthenticationToken verify(String token){
// Decode the token, verify it and get the subject
String username = JWT.require(Algorithm.HMAC512(SECRET.getBytes()))
.build()
.verify(token)
.getSubject();
// If username is not null, get the UserDetails and return a new UsernamePasswordAuthenticationToken
if(username != null){
UserDetails userDetails = userDetailsService.loadUserByUsername(username);
return new UsernamePasswordAuthenticationToken(userDetails,
null, userDetails.getAuthorities());
}
return null;
}
}
getJWT 方法试图用DTO中的用户名找到用户。然后,它试图使用给定的凭证和存储在UserDetails 中的授权来验证用户。如果验证成功,我们就创建JWT令牌。我们将主题设置为他们的用户名和从当前时间开始的10小时的到期日。
然后,我们用特定的加密算法和我们的秘密来签署它。在verify 方法中,我们使用我们的秘密解码给定的JWT令牌,验证它并检索主题,这将是用户。然后我们检索出用户的UserDetails 。使用UserDetails ,我们创建并返回一个UsernamePasswordAuthenticationToken 对象。我们将使用这个对象来告诉Spring是谁发出的请求。
现在我们有了一个创建和验证JWT令牌的服务,我们需要创建一个端点来检索JWT。
@PostMapping("/api/auth/login")
public String JWTLogin(@RequestBody UserDto userDto){
return authService.getJWT(userDto);
}
最后,我们需要添加一个过滤器来验证每个请求中发送的JWT令牌。如果你不知道,过滤器是一个用于拦截请求的类。
它们是使Spring安全在幕后工作的基础。
package me.john.amiscaray.springsecuritydemo.filter;
import me.john.amiscaray.springsecuritydemo.services.JWTAuthService;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.security.authentication.AuthenticationManager;
import org.springframework.security.authentication.UsernamePasswordAuthenticationToken;
import org.springframework.security.core.context.SecurityContextHolder;
import org.springframework.security.web.authentication.www.BasicAuthenticationFilter;
import org.springframework.stereotype.Component;
import org.springframework.web.filter.OncePerRequestFilter;
import javax.servlet.FilterChain;
import javax.servlet.ServletException;
import javax.servlet.http.HttpServletRequest;
import javax.servlet.http.HttpServletResponse;
import java.io.IOException;
public class JWTFilter extends BasicAuthenticationFilter {
private final JWTAuthService authService;
public JWTFilter(AuthenticationManager authenticationManager, JWTAuthService authService){
super(authenticationManager);
this.authService = authService;
}
@Override
protected void doFilterInternal(HttpServletRequest httpServletRequest, HttpServletResponse httpServletResponse, FilterChain filterChain) throws ServletException, IOException {
// Get Authorization header
String authorizationHeader = httpServletRequest.getHeader("Authorization");
// Remove the "Bearer" prefix
String token = authorizationHeader.substring(7);
// Verify token
UsernamePasswordAuthenticationToken auth = authService.verify(token);
SecurityContextHolder.getContext().setAuthentication(auth);
// send request through next filter
filterChain.doFilter(httpServletRequest, httpServletResponse);
}
}
现在我们有了这个过滤器,我们需要更新我们的安全配置来应用它。然后我们需要删除HTTP basic,并确保在发送登录或注册请求时不检查JWT令牌。
@Autowired
private AuthenticationManager authenticationManager;
@Override
protected void configure(HttpSecurity http) throws Exception {
http.csrf().disable()
.authorizeRequests()
.antMatchers("/api/secret-admin-business").hasAnyRole("ADMIN")
.anyRequest().authenticated()
.and()
.addFilter(new JWTFilter(authenticationManager, authService))
// Remove sessions since we are now using JWT
.sessionManagement().sessionCreationPolicy(SessionCreationPolicy.STATELESS);
}
@Override
public void configure(WebSecurity web) throws Exception {
web.ignoring()
.antMatchers("/api/auth/signup")
.antMatchers("/api/auth/login");
}
请注意:AuthenticationManager需要密码编码器,而SecurityConfig需要AuthenticationManager。因为SecurityConfig包含PasswordEncoder Bean,所以可能会出现一个循环依赖的问题。为了解决这个问题,你需要在另一个类中声明PasswordEncoder bean,以实现这个设置。
实现OAuth
现在让我们来看看我们如何使用OAuth作为我们的认证策略。作为我们的认证提供者,我们将使用GitHub。为了简单起见,让我们从头开始做一个新的Spring Boot项目。这个项目将使用Spring Security、OAuth2客户端和Spring Web依赖。为了让我们有一个可以作为主页的东西,我们将添加以下控制器。
@RestController
public class HomeController {
@GetMapping("/")
public String getWelcomeMessage(){
return "Hello User!";
}
}
然后,进入GitHub,点击设置,开发者设置,OAuth应用程序,并注册一个新的应用程序。
然后添加以下属性。

授权回调URL,是我们在认证时将发送给用户的URL。注册应用程序后,Github会给出一个client ID ,并选择生成一个client secret 。我们将需要把这些添加到我们的spring项目的属性中。
为了简单起见,我们将把属性设置为YAML文件而不是通常的属性文件。你只需将application.properties 文件重命名为application.yml 。
然后添加以下配置。
spring:
security:
oauth2:
client:
registration:
github:
clientId: YOUR-CLIENT-ID
clientSecret: YOUR-CLIENT-SECRET
然后,当你运行该应用程序并进入主页时,你应该看到以下内容。

结论
在本指南中,我们介绍了如何使用Spring的许多关键安全功能。虽然我们讲得比较快,但希望本指南能让你对如何保护Spring Boot应用程序有一个很好的了解。作为下一步,我建议尝试使用这些知识来保护你现有的应用程序。