基于Spring Boot项目使用Spring Security+OAuth2.0+JWT搭建用户认证中心。
Spring Security是一个强大且高度可定制的身份验证和访问控制框架。它是用于保护基于Spring的应用程序的实际标准。Spring Security官网
用户登录成功后,会创建一个session保存在服务器端,session id保存在cookie中
使用token作为唯一标识
OAuth 2.0 是授权的行业标准协议。OAuth 2.0 侧重于客户端开发人员的简单性,同时为 Web 应用程序、桌面应用程序、移动电话和客厅设备提供特定的授权流。该规范及其扩展正在IETF非授权工作组内制定。
JSON Web Token (JWT) 是一个开放标准(RFC 7519),它定义了一种紧凑且自成一体的方式,将各方之间安全传输信息作为 JSON 对象。此信息可以进行验证和信任,因为它是以数字方式签名的。JWTs 可以使用秘密(使用HMAC算法)或使用RSA或ECDSA的公钥/私钥对进行签名。
新建一个Spring Boot项目然后通过maven引入所需依赖就可以。需要注意的是不同版本的Spring Boot和依赖包可能会出现方法过时等问题。本demo的Spring Boot的版本是2.4.4。下面是所有依赖包。
<!-- 核心依赖 -->
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter</artifactId>
</dependency>
<!-- 测试依赖 -->
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-test</artifactId>
<scope>test</scope>
</dependency>
<!-- web -->
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-web</artifactId>
</dependency>
<!-- security -->
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-security</artifactId>
</dependency>
<!-- lombok -->
<dependency>
<groupId>org.projectlombok</groupId>
<artifactId>lombok</artifactId>
<optional>true</optional>
</dependency>
<!-- mybatis-plus -->
<dependency>
<groupId>com.baomidou</groupId>
<artifactId>mybatis-plus-boot-starter</artifactId>
<version>3.4.2</version>
</dependency>
<!-- mysql -->
<dependency>
<groupId>mysql</groupId>
<artifactId>mysql-connector-java</artifactId>
<version>8.0.22</version>
</dependency>
<!-- 数据源驱动包 -->
<dependency>
<groupId>com.alibaba</groupId>
<artifactId>druid-spring-boot-starter</artifactId>
<version>1.2.4</version>
</dependency>
<!-- hutool -->
<dependency>
<groupId>cn.hutool</groupId>
<artifactId>hutool-all</artifactId>
<version>5.5.8</version>
</dependency>
<!-- jwt -->
<dependency>
<groupId>org.springframework.security</groupId>
<artifactId>spring-security-jwt</artifactId>
<version>1.1.1.RELEASE</version>
</dependency>
<!-- jwt工具 -->
<dependency>
<groupId>com.auth0</groupId>
<artifactId>java-jwt</artifactId>
<version>3.3.0</version>
</dependency>
<!--Spring boot Redis-->
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-data-redis</artifactId>
</dependency>
<!-- 日志 -->
<dependency>
<groupId>log4j</groupId>
<artifactId>log4j</artifactId>
<version>1.2.17</version>
</dependency>
<!-- oauth2.0 -->
<dependency>
<groupId>org.springframework.security.oauth</groupId>
<artifactId>spring-security-oauth2</artifactId>
<version>2.3.6.RELEASE</version>
</dependency>
在引入Spring Security依赖之后启动项目,访问http://localhost:8080/login,Spring Security自带了一个登陆页面。如图
这就说明Spring Security使用成功。用户名默认是user,密码在启动项目时会显示在控制台。
接下来我们对Spring Security进行改造,实现一些我们自己的需求。
数据库连接省略
1.创建配置类
@Configuration
@EnableWebSecurity
@EnableGlobalMethodSecurity(prePostEnabled = true) // 开启方法级别安全
public class WebSecurityConfig extends WebSecurityConfigurerAdapter {
@Resource
private UserDetailsServiceImpl userDetailsService;
@Bean
public BCryptPasswordEncoder bCryptPasswordEncoder() {
return new BCryptPasswordEncoder();
}
/**
* 一些简单的配置
*/
@Override
protected void configure(HttpSecurity http) throws Exception {
// 禁用session
http.sessionManagement().disable();
http.csrf().disable();
http.formLogin()
// 自定义登录成功后路径
.defaultSuccessUrl("/hello")
// 自定义登录路径
.loginProcessingUrl("/doLogin");
// 可以匿名访问
http.authorizeRequests()
.antMatchers("/doLogin").anonymous()
.anyRequest().authenticated();
}
@Override
protected void configure(AuthenticationManagerBuilder auth) throws Exception {
// 用户数据从数据库获取
auth.userDetailsService(userDetailsService);
}
}
2.自定义身份认证,创建一个类实现UserDetailsService
@Service
public class UserDetailsServiceImpl implements UserDetailsService {
@Resource
private BCryptPasswordEncoder bCryptPasswordEncoder;
@Resource
private UserMapper userMapper;
@Override
public UserDetails loadUserByUsername(String s) throws UsernameNotFoundException {
/* 用户在登录时,会进入此方法(在配置类中进行配置),参数是用户名,这里使用了mybatisplus
* 做了一个简单的通过用户名查询用户,springsecurity会自动对密码进行匹配
*/
QueryWrapper<com.springboot.demo.security.entity.User> wrapper = new QueryWrapper<>();
wrapper.eq("username", s);
com.springboot.demo.security.entity.User sysUser = userMapper.selectOne(wrapper);
String password = sysUser.getPassword();
List<GrantedAuthority> userList = new ArrayList<>();
// userList是权限集合,这里也是做一个简单的权限添加
userList.add(new SimpleGrantedAuthority("add"));
// springsecurity5.0后密码需要加密一次,不然会报错
return new User("user", bCryptPasswordEncoder.encode(password), userList);
}
3.访问登录页进行测试
登录成功后跳转到自己配置的登录成功页面
使用注解进行权限控制首先需要在配置类中加上注解@EnableGlobalMethodSecurity(prePostEnabled = true),表示开启方法级安全级别。有三种机制,这里使用@PreAuthorize注解,在上述登录中我们把add这个权限分配给了用户,所以登录后直接访问的hello没有权限限制可以直接访问
@GetMapping(value = "/hello")
public String hello() {
return "hello";
}
@PreAuthorize("hasAuthority('add')")
@GetMapping(value = "/add")
public String add() {
return "add";
}
@PreAuthorize("hasAuthority('del')")
@GetMapping(value = "/del")
public String del() {
return "del";
}
有add的权限也可以访问add
访问del就会提示授权异常
springsecurity的简单应用就大功告成。
首先引入所需maven依赖,然后我们创建一个配置类AuthorizationJwtServerConfig集成AuthorizationServerConfigurerAdapter,先使用redis做token存储。
@Configuration
@EnableAuthorizationServer
public class AuthorizationJwtServerConfig extends AuthorizationServerConfigurerAdapter {
@Resource
private RedisConnectionFactory redisConnectionFactory;
@Bean
public TokenStore tokenStore() {
return new RedisTokenStore(redisConnectionFactory);
}
/**
* 暴露授权服务
* @param endpoints
* @throws Exception
*/
@Override
public void configure(AuthorizationServerEndpointsConfigurer endpoints) throws Exception {
endpoints.tokenStore(tokenStore());
}
}
然后启动项目,我们可以在控制台中看到打印的内容,说明oauth2.0可以使用。
通过code码换取token。
在AuthorizationJwtServerConfig配置类添加配置。
@Override
public void configure(ClientDetailsServiceConfigurer clients) throws Exception {
clients.inMemory()
// 第三方应用客户端id,相当于账号,可自定义
.withClient("web")
// 第三方应用密码,需要加密,相当于密码,可自定义
.secret(new BCryptPasswordEncoder().encode("web"))
// 第三方作用域,自定义
.scopes("read")
// 授权类型,使用code码
.authorizedGrantTypes("authorization_code")
// 有效时间
.accessTokenValiditySeconds(7200)
// 重定向url,必须是公网地址,必须是https
.redirectUris("https://www.baidu.com");
}
重启项目后我们在浏览器上访问http://localhost:8080/oauth/authorize?response_type=code&scope=read&client_id=web&redirect_uri=https://www.baidu.com
response_type参数是授权类型code是code码授权,scope作用域对应代码配置中的作用域值,client_id也是对应代码中的配置,redirect_uri同理。
回车之后会跳转到登录页面,然后输入账号密码再次登录,会跳转到授权页面
确认授权后会跳转到我们配置的重定向地址,并且获得了code码
有了code码之后我们就要用code码去换取token,使用工具postman发送一个post请求
除此之外还需要配置Authorization,类型选择Basic Auth,账号密码是代码中配置的
发送请求之后就会获得token了
直接获取token,代码跟code码授权没有太大区别,类型换成implicit静默授权就可以了
@Override
public void configure(ClientDetailsServiceConfigurer clients) throws Exception {
clients.inMemory()
// 第三方应用客户端id
.withClient("app")
// 第三方应用密码,需要加密
.secret(new BCryptPasswordEncoder().encode("app"))
// 第三方作用域
.scopes("read")
// 授权类型
.authorizedGrantTypes("implicit")
.accessTokenValiditySeconds(7200)
.redirectUris("https://www.baidu.com");
}
重启项目,浏览器访问http://localhost:8080/oauth/authorize?response_type=token&scope=read&client_id=app&redirect_uri=https://www.baidu.com
注意这里的response_type的值是token,直接访问也会跳转到登录页面,登录成功后进行授权
授权成功后,token会显示在浏览器上
@Override
public void configure(ClientDetailsServiceConfigurer clients) throws Exception {
clients.inMemory()
// 第三方应用客户端id
.withClient("client")
// 第三方应用密码,需要加密
.secret(new BCryptPasswordEncoder().encode("client"))
// 第三方作用域
.scopes("read")
// 授权类型
.authorizedGrantTypes("client_credentials")
.accessTokenValiditySeconds(7200)
.redirectUris("https://www.baidu.com");
}
使用postman发送post请求获取token,只需grant_type一个参数
首先更改WebSecurityConfig配置类,增加一个认证管理器
/**
* 用密码模式授权认证管理器
* @return
* @throws
*/
@Override
@Bean
public AuthenticationManager authenticationManager() throws Exception {
return super.authenticationManager();
}
AuthorizationJwtServerConfig类中把AuthenticationManager注入进来,暴露授权服务中加入认证管理器
@Resource
private AuthenticationManager authenticationManager;
@Override
public void configure(ClientDetailsServiceConfigurer clients) throws Exception {
clients.inMemory()
// 第三方应用客户端id
.withClient("qq")
// 第三方应用密码,需要加密
.secret(new BCryptPasswordEncoder().encode("qq"))
// 第三方作用域
.scopes("read")
// 授权类型
.authorizedGrantTypes("password")
.accessTokenValiditySeconds(7200)
.redirectUris("https://www.baidu.com");
}
/**
* 暴露授权服务
* @param endpoints
* @throws Exception
*/
@Override
public void configure(AuthorizationServerEndpointsConfigurer endpoints) throws Exception {
endpoints.tokenStore(tokenStore()).authenticationManager(authenticationManager);
}
重启项目后使用postman来测试,发送post请求
Authorization也需要配置
成功后直接获取token
使用jwt生成token,并做token的验证
@Configuration
public class JwtTokenFilter extends OncePerRequestFilter {
@Override
protected void doFilterInternal(HttpServletRequest httpServletRequest, HttpServletResponse httpServletResponse, FilterChain filterChain) throws ServletException, IOException {
String path = httpServletRequest.getRequestURI();
String method = httpServletRequest.getMethod();
// 对于登录直接放行
if ("/login".equals(path) && "POST".equals(method)) {
filterChain.doFilter(httpServletRequest, httpServletResponse);
return;
}
// 获取token并验证
String authorization = httpServletRequest.getHeader("Authorization");
if (!StrUtil.hasBlank(authorization)) {
String jwt = authorization.replaceAll("bearer ", "");
// 创建一个token解析器(test作为jwt生成token的签名是自定义的,一般是作为配置固定值)
JWTVerifier jwtVerifier = JWT.require(Algorithm.HMAC256("test")).build();
DecodedJWT decodedJwt;
try {
decodedJwt = jwtVerifier.verify(jwt);
} catch (Exception e) {
httpServletResponse.getWriter().write("token验证失败");
return;
}
// 获取用户名,密码,角色权限
String username = decodedJwt.getClaim("username").asString();
String password = decodedJwt.getClaim("password").asString();
List<String> roles = decodedJwt.getClaim("role").asList(String.class);
List<SimpleGrantedAuthority> roleList = new ArrayList<>();
roles.forEach(role -> {
roleList.add(new SimpleGrantedAuthority(role));
});
UsernamePasswordAuthenticationToken usernamePasswordAuthenticationToken =
new UsernamePasswordAuthenticationToken(username, password, roleList);
SecurityContextHolder.getContext().setAuthentication(usernamePasswordAuthenticationToken);
filterChain.doFilter(httpServletRequest, httpServletResponse);
return;
}
httpServletResponse.getWriter().write("token验证失败");
}
}
@Configuration
@EnableWebSecurity
@EnableGlobalMethodSecurity(prePostEnabled = true)
public class WebSecurityConfig extends WebSecurityConfigurerAdapter {
@Resource
private UserDetailsServiceImpl userDetailsService;
@Resource
private JwtTokenFilter jwtTokenFilter;
@Resource
private RedisTemplate<String, Object> redisTemplate;
@Bean
public BCryptPasswordEncoder bCryptPasswordEncoder() {
return new BCryptPasswordEncoder();
}
/**
* 一些简单的配置
*/
@Override
protected void configure(HttpSecurity http) throws Exception {
// 登录之前验证token
http.addFilterBefore(jwtTokenFilter, UsernamePasswordAuthenticationFilter.class);
// 禁用session
http.sessionManagement().disable();
http.csrf().disable();
http.formLogin()
// 登录成功处理器
.successHandler(authenticationSuccessHandler())
// 登录失败处理器
.failureHandler(authenticationFailureHandler());
// 除了登录可以匿名访问
http.authorizeRequests()
.antMatchers("/login").anonymous()
.anyRequest().authenticated();
}
@Override
protected void configure(AuthenticationManagerBuilder auth) throws Exception {
// 用户数据从数据库获取
auth.userDetailsService(userDetailsService);
}
/**
* 登录成功处理器
*/
@Bean
public AuthenticationSuccessHandler authenticationSuccessHandler() {
return ((httpServletRequest, httpServletResponse, authentication) -> {
httpServletResponse.setContentType("application/json;charset=utf-8");
User user = (User)authentication.getPrincipal();
// 用户名
String username = user.getUsername();
// 密码
String password = user.getPassword();
// 权限
Collection<GrantedAuthority> grantedAuthorities = user.getAuthorities();
List<String> roleList = new ArrayList<>();
grantedAuthorities.forEach(grantedAuthority -> {
roleList.add(grantedAuthority.getAuthority());
});
String[] roles = new String[roleList.size()];
// 用jwt生成token
HashMap<String, Object> headMap = new HashMap<>(16);
// 使用的算法
headMap.put("alg", "HS256");
headMap.put("typ", "JWT");
Date nowDate = new Date();
// 过期时间可以自定义
Date expDate = new Date(nowDate.getTime() + 2 * 60 * 60 * 1000);
String jwt = JWT.create().withHeader(headMap)
.withIssuedAt(nowDate)
.withExpiresAt(expDate)
// 主题,自定义
.withSubject("demo")
.withClaim("username", username)
.withClaim("password", password)
.withArrayClaim("role", roleList.toArray(roles))
// 签名,自定义,同一个项目中签名是唯一
.sign(Algorithm.HMAC256("test"));
// 保存token到redis
redisTemplate.opsForValue().set("token:" + jwt, user, 7200);
// 返回token
HashMap<String, Object> hashMap = new HashMap<>(16);
hashMap.put("username", username);
hashMap.put("create_time", nowDate);
hashMap.put("expires_time", expDate);
hashMap.put("access_token", jwt);
hashMap.put("type", "bearer");
ObjectMapper objectMapper = new ObjectMapper();
String s = objectMapper.writeValueAsString(hashMap);
PrintWriter printWriter = httpServletResponse.getWriter();
printWriter.write(s);
printWriter.flush();
printWriter.close();
});
}
/**
* 登录失败处理器
*/
@Bean
public AuthenticationFailureHandler authenticationFailureHandler() {
return ((httpServletRequest, httpServletResponse, e) -> {
HashMap<String, Object> hashMap = new HashMap<>(16);
hashMap.put("error", e);
hashMap.put("message", "登录失败");
ObjectMapper objectMapper = new ObjectMapper();
String s = objectMapper.writeValueAsString(hashMap);
PrintWriter printWriter = httpServletResponse.getWriter();
printWriter.write(s);
printWriter.flush();
printWriter.close();
});
}
}
@Configuration
@EnableAuthorizationServer
public class AuthorizationJwtServerConfig extends AuthorizationServerConfigurerAdapter {
@Bean
public TokenStore tokenStore() {
return new JwtTokenStore(jwtAccessTokenConverter());
}
/**
* jwt token转换器
* @return
*/
@Bean
public JwtAccessTokenConverter jwtAccessTokenConverter() {
JwtAccessTokenConverter jwtAccessTokenConverter = new JwtAccessTokenConverter();
// 签名
jwtAccessTokenConverter.setSigningKey("test");
return jwtAccessTokenConverter;
}
/**
* 暴露授权服务
* @param endpoints
* @throws Exception
*/
@Override
public void configure(AuthorizationServerEndpointsConfigurer endpoints) throws Exception {
endpoints.accessTokenConverter(jwtAccessTokenConverter());
}
}
然后启动项目,使用postman测试
登录获取token
使用token访问接口
登出就相当于是删除redis缓存的token
@PostMapping(value = "/doLogout")
public Object logout() {
ServletRequestAttributes requestAttributes = (ServletRequestAttributes) RequestContextHolder.getRequestAttributes();
HttpServletRequest request = requestAttributes.getRequest();
String head = request.getHeader("Authorization");
if (!StrUtil.isBlank(head)) {
String jwt = head.replaceAll("bearer ", "");
if (!StrUtil.hasBlank(jwt)) {
redisTemplate.delete("token:" + jwt);
return "登出成功";
}
}
return "登出失败";
}