Spring Security项目涉及到的重要类说明以及可以使用的地方

Spring Security 介绍

Spring Security 应该属于 Spring 全家桶中学习曲线比较陡峭的几个模块之一,下面我将从起源和定义这两个方面来简单介绍一下它。

起源: Spring Security 实际上起源于 Acegi Security,这个框架能为基于 Spring 的企业应用提供强大而灵活安全访问控制解决方案,并且框架这个充分利用 Spring 的 IoC 和 AOP 功能,提供声明式安全访问控制的功能。后面,随着这个项目发展, Acegi Security 成为了Spring官方子项目,后来被命名为 “Spring Security”。

**定义:**Spring Security 是一个功能强大且高度可以定制的框架,侧重于为Java 应用程序提供身份验证和授权。——官方介绍。

Session 和 Token 认证对比

Session 认证图解

很多时候我们都是通过 SessionID 来实现特定的用户,SessionID 一般会选择存放在 Redis 中。举个例子:用户成功登陆系统,然后返回给客户端具有 SessionID 的 Cookie,当用户向后端发起请求的时候会把 SessionID 带上,这样后端就知道你的身份状态了。

关于这种认证方式更详细的过程如下:

用户向服务器发送用户名和密码用于登陆系统。

服务器验证通过后,服务器为用户创建一个 Session,并将 Session信息存储 起来。

服务器向用户返回一个 SessionID,写入用户的 Cookie。

当用户保持登录状态时,Cookie 将与每个后续请求一起被发送出去。

服务器可以将存储在 Cookie 上的 Session ID 与存储在内存中或者数据库中的 Session 信息进行比较,以验证用户的身份,返回给用户客户端响应信息的时候会附带用户当前的状态。

Token 认证图解

在基于 Token 进行身份验证的的应用程序中,服务器通过Payload、Header和一个密钥(secret)创建令牌(Token)并将 Token 发送给客户端,客户端将 Token 保存在 Cookie 或者 localStorage 里面,以后客户端发出的所有请求都会携带这个令牌。你可以把它放在 Cookie 里面自动发送,但是这样不能跨域,所以更好的做法是放在 HTTP Header 的 Authorization字段中: Authorization: Bearer Token。

关于这种认证方式更详细的过程如下:

用户向服务器发送用户名和密码用于登陆系统。

身份验证服务响应并返回了签名的 JWT,上面包含了用户是谁的内容。

用户以后每次向后端发请求都在 Header 中带上 JWT。

服务端检查 JWT 并从中获取用户相关信息。

项目涉及到的重要类说明

配置类

在本项目中我们自定义 SecurityConfig 继承了 WebSecurityConfigurerAdapter。 WebSecurityConfigurerAdapter提供HttpSecurity来配置 cors,csrf,会话管理和受保护资源的规则。

配置类中我们主要配置了:

密码编码器 BCryptPasswordEncoder(存入数据库的密码需要被加密)。

为 AuthenticationManager 设置自定义的 UserDetailsService以及密码编码器;

在 Spring Security 配置指定了哪些路径下的资源需要验证了的用户才能访问、哪些不需要以及哪些资源只能被特定角色访问;

将我们自定义的两个过滤器添加到 Spring Security 配置中;

将两个自定义处理权限认证方面的异常类添加到 Spring Security 配置中;

@EnableWebSecurity

@EnableGlobalMethodSecurity(prePostEnabled = true)

public class SecurityConfig extends WebSecurityConfigurerAdapter {

@Autowired

UserDetailsServiceImpl userDetailsServiceImpl;

/**

* 密码编码器

*/

@Bean

public BCryptPasswordEncoder bCryptPasswordEncoder() {

return new BCryptPasswordEncoder();

}

@Bean

public UserDetailsService createUserDetailsService() {

return userDetailsServiceImpl;

}

@Override

protected void configure(AuthenticationManagerBuilder auth) throws Exception {

// 设置自定义的userDetailsService以及密码编码器

auth.userDetailsService(userDetailsServiceImpl).passwordEncoder(bCryptPasswordEncoder());

}

@Override

protected void configure(HttpSecurity http) throws Exception {

http.cors().and()

// 禁用 CSRF

.csrf().disable()

.authorizeRequests()

.antMatchers(HttpMethod.POST, "/auth/login").permitAll()

// 指定路径下的资源需要验证了的用户才能访问

.antMatchers("/api/**").authenticated()

.antMatchers(HttpMethod.DELETE, "/api/**").hasRole("ADMIN")

// 其他都放行了

.anyRequest().permitAll()

.and()

//添加自定义Filter

.addFilter(new JWTAuthenticationFilter(authenticationManager()))

.addFilter(new JWTAuthorizationFilter(authenticationManager()))

// 不需要session(不创建会话)

.sessionManagement().sessionCreationPolicy(SessionCreationPolicy.STATELESS).and()

// 授权异常处理

.exceptionHandling().authenticationEntryPoint(new JWTAuthenticationEntryPoint())

.accessDeniedHandler(new JWTAccessDeniedHandler());

}

}

跨域:

在这里踩的一个坑是:如果你没有设置exposedHeaders("Authorization")暴露 header 中的"Authorization"属性给客户端应用程序的话,前端是获取不到 token 信息的。

@Configuration

public class CorsConfiguration implements WebMvcConfigurer {

@Override

public void addCorsMappings(CorsRegistry registry) {

registry.addMapping("/**")

.allowedOrigins("*")

//暴露header中的其他属性给客户端应用程序

//如果不设置这个属性前端无法通过response header获取到Authorization也就是token

.exposedHeaders("Authorization")

.allowCredentials(true)

.allowedMethods("GET", "POST", "DELETE", "PUT")

.maxAge(3600);

}

}

工具类

/**

* @author shuang.kou

*/

public class JwtTokenUtils {

/**

* 生成足够的安全随机密钥,以适合符合规范的签名

*/

private static byte[] apiKeySecretBytes = DatatypeConverter.parseBase64Binary(SecurityConstants.JWT_SECRET_KEY);

private static SecretKey secretKey = Keys.hmacShaKeyFor(apiKeySecretBytes);

public static String createToken(String username, List roles, boolean isRememberMe) {

long expiration = isRememberMe ? SecurityConstants.EXPIRATION_REMEMBER : SecurityConstants.EXPIRATION;

String tokenPrefix = Jwts.builder()

.setHeaderParam("typ", SecurityConstants.TOKEN_TYPE)

.signWith(secretKey, SignatureAlgorithm.HS256)

.claim(SecurityConstants.ROLE_CLAIMS, String.join(",", roles))

.setIssuer("SnailClimb")

.setIssuedAt(new Date())

.setSubject(username)

.setExpiration(new Date(System.currentTimeMillis() + expiration * 1000))

.compact();

return SecurityConstants.TOKEN_PREFIX + tokenPrefix;

}

private boolean isTokenExpired(String token) {

Date expiredDate = getTokenBody(token).getExpiration();

return expiredDate.before(new Date());

}

public static String getUsernameByToken(String token) {

return getTokenBody(token).getSubject();

}

/**

* 获取用户所有角色

*/

public static List getUserRolesByToken(String token) {

String role = (String) getTokenBody(token)

.get(SecurityConstants.ROLE_CLAIMS);

return Arrays.stream(role.split(","))

.map(SimpleGrantedAuthority::new)

.collect(Collectors.toList());

}

private static Claims getTokenBody(String token) {

return Jwts.parser()

.setSigningKey(secretKey)

.parseClaimsJws(token)

.getBody();

}

}

获取保存在服务端的用户信息类

Spring Security 提供的 UserDetailsService有一个通过名字返回 Spring Security 可用于身份验证的UserDetails对象的方法:loadUserByUsername()。

package org.springframework.security.core.userdetails;

/**

*加载用户特定数据的核心接口。

*/

public interface UserDetailsService {

UserDetails loadUserByUsername(String username) throws UsernameNotFoundException;

}

UserDetails包含用于构建认证对象的必要信息(例如:用户名,密码)。

package org.springframework.security.core.userdetails;

/**

*提供用户核心信息的借口

*/

public interface UserDetails extends Serializable {

Collection getAuthorities();

String getPassword();

String getUsername();

boolean isAccountNonExpired();

boolean isAccountNonLocked();

boolean isCredentialsNonExpired();

boolean isEnabled();

}

一般情况下我们需要实现 UserDetailsService 借口并重写其中的 loadUserByUsername() 方法。

@Service

public class UserDetailsServiceImpl implements UserDetailsService {

private final UserService userService;

public UserDetailsServiceImpl(UserService userService) {

this.userService = userService;

}

@Override

public UserDetails loadUserByUsername(String name) throws UsernameNotFoundException {

User user = userService.findUserByUserName(name);

return new JwtUser(user);

}

}

认证过滤器(重要)

第一个过滤器主要JWTAuthenticationFilter用于根据用户的用户名和密码进行登录验证(用户请求中必须有用户名和密码这两个参数),为此我们继承了 UsernamePasswordAuthenticationFilter 并且重写了下面三个方法:

attemptAuthentication(): 验证用户身份。

successfulAuthentication() : 用户身份验证成功后调用的方法。

unsuccessfulAuthentication(): 用户身份验证失败后调用的方法。

/**

* @author shuang.kou

* 如果用户名和密码正确,那么过滤器将创建一个JWT Token 并在HTTP Response 的header中返回它,格式:token: "Bearer +具体token值"

*/

public class JWTAuthenticationFilter extends UsernamePasswordAuthenticationFilter {

private ThreadLocal rememberMe = new ThreadLocal<>();

private AuthenticationManager authenticationManager;

public JWTAuthenticationFilter(AuthenticationManager authenticationManager) {

this.authenticationManager = authenticationManager;

// 设置登录请求的 URL

super.setFilterProcessesUrl(SecurityConstants.AUTH_LOGIN_URL);

}

@Override

public Authentication attemptAuthentication(HttpServletRequest request,

HttpServletResponse response) throws AuthenticationException {

ObjectMapper objectMapper = new ObjectMapper();

try {

// 从输入流中获取到登录的信息

LoginUser loginUser = objectMapper.readValue(request.getInputStream(), LoginUser.class);

rememberMe.set(loginUser.getRememberMe());

// 这部分和attemptAuthentication方法中的源码是一样的,

// 只不过由于这个方法源码的是把用户名和密码这些参数的名字是死的,所以我们重写了一下

UsernamePasswordAuthenticationToken authRequest = new UsernamePasswordAuthenticationToken(

loginUser.getUsername(), loginUser.getPassword());

return authenticationManager.authenticate(authRequest);

} catch (IOException e) {

e.printStackTrace();

return null;

}

}

/**

* 如果验证成功,就生成token并返回

*/

@Override

protected void successfulAuthentication(HttpServletRequest request,

HttpServletResponse response,

FilterChain chain,

Authentication authentication) {

JwtUser jwtUser = (JwtUser) authentication.getPrincipal();

List roles = jwtUser.getAuthorities()

.stream()

.map(GrantedAuthority::getAuthority)

.collect(Collectors.toList());

// 创建 Token

String token = JwtTokenUtils.createToken(jwtUser.getUsername(), roles, rememberMe.get());

// Http Response Header 中返回 Token

response.setHeader(SecurityConstants.TOKEN_HEADER, token);

}

@Override

protected void unsuccessfulAuthentication(HttpServletRequest request, HttpServletResponse response, AuthenticationException authenticationException) throws IOException {

response.sendError(HttpServletResponse.SC_UNAUTHORIZED, authenticationException.getMessage());

}

}

这个过滤器中有几个比较重要的地方说明:

1.UsernamePasswordAuthenticationToken:从登录请求中获取{用户名,密码},AuthenticationManager将使用它来认证登录帐户。

2.authenticationManager.authenticate(authRequest):这段代码主要对用户进行认证,当执行这段代码的时候会跳到UserDetailsServiceImpl中去调用loadUserByUsername()方法来验证(我们在配置类中配置了AuthenticationManager使用自定义的UserDetailsServiceImpl去验证用户信息)。当验证成功后会返回一个完整填充的Authentication对象(包括授予的权限),然后会去调用successfulAuthentication方法。

package org.springframework.security.authentication;

/**

*尝试验证Authentication对象,如果成功,将返回一个完整填充的Authentication对象(包括授予的权限)。

*/

public interface AuthenticationManager {

Authentication authenticate(Authentication authentication)

throws AuthenticationException;

}

授权过滤器(重要)

这个过滤器继承了 BasicAuthenticationFilter,主要用于处理身份认证后才能访问的资源,它会检查 HTTP 请求是否存在带有正确令牌的 Authorization 标头并验证 token 的有效性。

当用户使用 token 对需要权限才能访问的资源进行访问的时候,这个类是主要用到的,下面按照步骤来说一说每一步到底都做了什么。

1.当用户使用系统返回的 token 信息进行登录的时候 ,会首先经过doFilterInternal()方法,这个方法会从请求的Header中取出 token 信息,然后判断 token 信息是否为空以及 token 信息格式是否正确。

2.如果请求头中有token 并且 token 的格式正确,则进行解析并判断 token 的有效性,然后会在 Spring Security 全局设置授权信息SecurityContextHolder.getContext().setAuthentication(getAuthentication(authorization));

/**

* 过滤器处理所有HTTP请求,并检查是否存在带有正确令牌的Authorization标头。例如,如果令牌未过期或签名密钥正确。

*

* @author shuang.kou

*/

public class JWTAuthorizationFilter extends BasicAuthenticationFilter {

private static final Logger logger = Logger.getLogger(JWTAuthorizationFilter.class.getName());

public JWTAuthorizationFilter(AuthenticationManager authenticationManager) {

super(authenticationManager);

}

@Override

protected void doFilterInternal(HttpServletRequest request,

HttpServletResponse response,

FilterChain chain) throws IOException, ServletException {

String authorization = request.getHeader(SecurityConstants.TOKEN_HEADER);

// 如果请求头中没有Authorization信息则直接放行了

if (authorization == null || !authorization.startsWith(SecurityConstants.TOKEN_PREFIX)) {

chain.doFilter(request, response);

return;

}

// 如果请求头中有token,则进行解析,并且设置授权信息

SecurityContextHolder.getContext().setAuthentication(getAuthentication(authorization));

super.doFilterInternal(request, response, chain);

}

/**

* 这里从token中获取用户信息并新建一个token

*/

private UsernamePasswordAuthenticationToken getAuthentication(String authorization) {

String token = authorization.replace(SecurityConstants.TOKEN_PREFIX, "");

try {

String username = JwtTokenUtils.getUsernameByToken(token);

// 通过 token 获取用户具有的角色

List userRolesByToken = JwtTokenUtils.getUserRolesByToken(token);

if (!StringUtils.isEmpty(username)) {

return new UsernamePasswordAuthenticationToken(username, null, userRolesByToken);

}

} catch (SignatureException | ExpiredJwtException exception) {

logger.warning("Request to parse JWT with invalid signature . Detail : " + exception.getMessage());

}

return null;

}

}

获取当前用户

我们在讲过滤器的时候说过,当认证成功的用户访问系统的时候,它的认证信息会被设置在 Spring Security 全局中。那么,既然这样,我们在其他地方获取到当前登录用户的授权信息也就很简单了,通过SecurityContextHolder.getContext().getAuthentication();方法即可。

SecurityContextHolder 保存 SecurityContext 的信息,SecurityContext 保存已通过认证的 Authentication 认证信息。

为此,我们实现了一个专门用来获取当前用户的类:

/**

* @author shuang.kou

* 获取当前请求的用户

*/

@Component

public class CurrentUser {

private final UserDetailsServiceImpl userDetailsService;

public CurrentUser(UserDetailsServiceImpl userDetailsService) {

this.userDetailsService = userDetailsService;

}

public JwtUser getCurrentUser() {

return (JwtUser) userDetailsService.loadUserByUsername(getCurrentUserName());

}

/**

* TODO:由于在JWTAuthorizationFilter这个类注入UserDetailsServiceImpl一致失败,

* 导致无法正确查找到用户,所以存入Authentication的Principal为从 token 中取出的当前用户的姓名

*/

private static String getCurrentUserName() {

Authentication authentication = SecurityContextHolder.getContext().getAuthentication();

if (authentication != null && authentication.getPrincipal() != null) {

return (String) authentication.getPrincipal();

}

return null;

}

}

异常相关

AccessDeniedHandler

JWTAccessDeniedHandler实现了AccessDeniedHandler主要用来解决认证过的用户访问需要权限才能访问的资源时的异常。

/**

* @author shuang.kou

* AccessDeineHandler 用来解决认证过的用户访问需要权限才能访问的资源时的异常

*/

public class JWTAccessDeniedHandler implements AccessDeniedHandler {

/**

* 当用户尝试访问需要权限才能的REST资源而权限不足的时候,

* 将调用此方法发送401响应以及错误信息

*/

@Override

public void handle(HttpServletRequest request, HttpServletResponse response, AccessDeniedException accessDeniedException) throws IOException {

accessDeniedException = new AccessDeniedException("Sorry you don not enough permissions to access it!");

response.sendError(HttpServletResponse.SC_FORBIDDEN, accessDeniedException.getMessage());

}

}

AuthenticationEntryPoint

JWTAuthenticationEntryPoint 实现了 AuthenticationEntryPoint 用来解决匿名用户访问需要权限才能访问的资源时的异常

/**

* @author shuang.kou

* AuthenticationEntryPoint 用来解决匿名用户访问需要权限才能访问的资源时的异常

*/

public class JWTAuthenticationEntryPoint implements AuthenticationEntryPoint {

/**

* 当用户尝试访问需要权限才能的REST资源而不提供Token或者Token过期时,

* 将调用此方法发送401响应以及错误信息

*/

@Override

public void commence(HttpServletRequest request,

HttpServletResponse response,

AuthenticationException authException) throws IOException {

response.sendError(HttpServletResponse.SC_UNAUTHORIZED, authException.getMessage());

}

}

验证权限配置的 Controller

这个是 UserControler 主要用来检测权限配置是否生效。

getAllUser()方法被注解 @PreAuthorize("hasAnyRole('ROLE_DEV','ROLE_PM')")修饰代表这个方法可以被DEV,PM 这两个角色访问,而deleteUserById() 被注解 @PreAuthorize("hasAnyRole('ROLE_ADMIN')")修饰代表只能被 ADMIN 访问。

/**

* @author shuang.kou

*/

@RestController

@RequestMapping("/api")

public class UserController {

private final UserService userService;

private final CurrentUser currentUser;

public UserController(UserService userService, CurrentUser currentUser) {

this.userService = userService;

this.currentUser = currentUser;

}

@GetMapping("/users")

@PreAuthorize("hasAnyRole('ROLE_DEV','ROLE_PM')")

public ResponseEntity> getAllUser(@RequestParam(value = "pageNum", defaultValue = "0") int pageNum, @RequestParam(value = "pageSize", defaultValue = "10") int pageSize) {

System.out.println("当前访问该接口的用户为:" + currentUser.getCurrentUser().toString());

Page allUser = userService.getAllUser(pageNum, pageSize);

return ResponseEntity.ok().body(allUser);

}

@DeleteMapping("/user")

@PreAuthorize("hasAnyRole('ROLE_ADMIN')")

public ResponseEntity deleteUserById(@RequestParam("username") String username) {

userService.deleteUserByUserName(username);

return ResponseEntity.ok().build();

}

}

欢迎大家点赞关注,转发,此外给大家整理了一些详细的资料,私信我“资料”即可获取

©著作权归作者所有,转载或内容合作请联系作者
  • 序言:七十年代末,一起剥皮案震惊了整个滨河市,随后出现的几起案子,更是在滨河造成了极大的恐慌,老刑警刘岩,带你破解...
    沈念sama阅读 212,294评论 6 493
  • 序言:滨河连续发生了三起死亡事件,死亡现场离奇诡异,居然都是意外死亡,警方通过查阅死者的电脑和手机,发现死者居然都...
    沈念sama阅读 90,493评论 3 385
  • 文/潘晓璐 我一进店门,熙熙楼的掌柜王于贵愁眉苦脸地迎上来,“玉大人,你说我怎么就摊上这事。” “怎么了?”我有些...
    开封第一讲书人阅读 157,790评论 0 348
  • 文/不坏的土叔 我叫张陵,是天一观的道长。 经常有香客问我,道长,这世上最难降的妖魔是什么? 我笑而不...
    开封第一讲书人阅读 56,595评论 1 284
  • 正文 为了忘掉前任,我火速办了婚礼,结果婚礼上,老公的妹妹穿的比我还像新娘。我一直安慰自己,他们只是感情好,可当我...
    茶点故事阅读 65,718评论 6 386
  • 文/花漫 我一把揭开白布。 她就那样静静地躺着,像睡着了一般。 火红的嫁衣衬着肌肤如雪。 梳的纹丝不乱的头发上,一...
    开封第一讲书人阅读 49,906评论 1 290
  • 那天,我揣着相机与录音,去河边找鬼。 笑死,一个胖子当着我的面吹牛,可吹牛的内容都是我干的。 我是一名探鬼主播,决...
    沈念sama阅读 39,053评论 3 410
  • 文/苍兰香墨 我猛地睁开眼,长吁一口气:“原来是场噩梦啊……” “哼!你这毒妇竟也来了?” 一声冷哼从身侧响起,我...
    开封第一讲书人阅读 37,797评论 0 268
  • 序言:老挝万荣一对情侣失踪,失踪者是张志新(化名)和其女友刘颖,没想到半个月后,有当地人在树林里发现了一具尸体,经...
    沈念sama阅读 44,250评论 1 303
  • 正文 独居荒郊野岭守林人离奇死亡,尸身上长有42处带血的脓包…… 初始之章·张勋 以下内容为张勋视角 年9月15日...
    茶点故事阅读 36,570评论 2 327
  • 正文 我和宋清朗相恋三年,在试婚纱的时候发现自己被绿了。 大学时的朋友给我发了我未婚夫和他白月光在一起吃饭的照片。...
    茶点故事阅读 38,711评论 1 341
  • 序言:一个原本活蹦乱跳的男人离奇死亡,死状恐怖,灵堂内的尸体忽然破棺而出,到底是诈尸还是另有隐情,我是刑警宁泽,带...
    沈念sama阅读 34,388评论 4 332
  • 正文 年R本政府宣布,位于F岛的核电站,受9级特大地震影响,放射性物质发生泄漏。R本人自食恶果不足惜,却给世界环境...
    茶点故事阅读 40,018评论 3 316
  • 文/蒙蒙 一、第九天 我趴在偏房一处隐蔽的房顶上张望。 院中可真热闹,春花似锦、人声如沸。这庄子的主人今日做“春日...
    开封第一讲书人阅读 30,796评论 0 21
  • 文/苍兰香墨 我抬头看了看天上的太阳。三九已至,却和暖如春,着一层夹袄步出监牢的瞬间,已是汗流浃背。 一阵脚步声响...
    开封第一讲书人阅读 32,023评论 1 266
  • 我被黑心中介骗来泰国打工, 没想到刚下飞机就差点儿被人妖公主榨干…… 1. 我叫王不留,地道东北人。 一个月前我还...
    沈念sama阅读 46,461评论 2 360
  • 正文 我出身青楼,却偏偏与公主长得像,于是被迫代替她去往敌国和亲。 传闻我的和亲对象是个残疾皇子,可洞房花烛夜当晚...
    茶点故事阅读 43,595评论 2 350

推荐阅读更多精彩内容