云计算百科
云计算领域专业知识百科平台

基于Spring Security 6的OAuth2 系列之十一 - 授权服务器--前后端分离授权服务器

之所以想写这一系列,是因为之前工作过程中使用Spring Security OAuth2搭建了网关和授权服务器,但当时基于spring-boot 2.3.x,其默认的Spring Security是5.3.x。之后新项目升级到了spring-boot 3.3.0,结果一看Spring Security也升级为6.3.0。无论是Spring Security的风格和以及OAuth2都做了较大改动,里面甚至将授权服务器模块都移除了,导致在配置同样功能时,花费了些时间研究新版本的底层原理,这里将一些学习经验分享给大家。

注意:由于框架不同版本改造会有些使用的不同,因此本次系列中使用基本框架是 spring-boo-3.3.0(默认引入的Spring Security是6.3.0),JDK版本使用的是19,本系列OAuth2的代码采用Spring Security6.3.0框架,所有代码都在oauth2-study项目上:https://github.com/forever1986/oauth2-study.git

目录

  • 1 前提准备
  • 2 新建项目及配置
  • 3 Spring Security配置
  • 4 授权服务器配置
  • 5 测试

前面从《系列之五 – 授权服务器–开篇》到《授权服务器–刷新token》比较详细的讲了Spring Security 6实现授权服务器的功能以及原理,这一章我们将结合前面的内容,做一个前后端分离的授权服务器。

代码参考lesson06子模块

1 前提准备

1)mysql数据库:可以使用《授权服务器–自定义数据库客户端信息》中的授权服务器oauth_study数据库,以及三张表。同时新建一张用户信息表用于Spring Security用户认证落库。

— oauth_study.t_user definition
CREATE TABLE oauth_study.`t_user` (
`id` bigint NOT NULL AUTO_INCREMENT,
`username` varchar(100) NOT NULL,
`password` varchar(100) NOT NULL,
`email` varchar(100) DEFAULT NULL,
`phone` varchar(100) CHARACTER SET utf8mb4 COLLATE utf8mb4_0900_ai_ci NOT NULL,
PRIMARY KEY (`id`)
) ENGINE=InnoDB AUTO_INCREMENT=6 DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_ai_ci;
INSERT INTO oauth_study.t_user (username, password, email, phone) VALUES('test', '{noop}1234', 'test@demo.com', '13788888888');

2)redis:用于存储登录信息 3)前后端分离的情况,授权模型下的请求流程如下:

在这里插入图片描述

对于授权服务器来说,我们要做的就是和前端的几个接口:

  • /login和/logout:登录和登出接口,不返回登录页面,由前端自己返回
  • /consentface:返回授权信息接口,不返回授权页面,由前端自己返回
  • /oauth2/authorize和/oauth2/token:保持不变即可

注意:我们不会写前端服务器,只有授权服务器。使用postman模拟过程。

4)实验模拟:我们将使用postman模拟从授权服务器获得最终access_token的过程。

  • 使用/login接口进行登录获得登录token
  • 使用/oauth2/authorize获取授权页面(不会返回页面,只是返回一些授权信息,其中最重要的state参数)
  • 使用/oauth2/authorize获得授权码(返回授权码及回调url)
  • 使用/oauth2/token获得access_token

2 新建项目及配置

1)新建lesson06子模块,其pom引入如下:

<dependencies>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-web</artifactId>
</dependency>
<dependency>
<groupId>org.springframework.security</groupId>
<artifactId>spring-security-oauth2-authorization-server</artifactId>
</dependency>
<!– lombok依赖,用于get/set的简便–>
<dependency>
<groupId>org.projectlombok</groupId>
<artifactId>lombok</artifactId>
</dependency>
<!– mysql依赖,用于连接mysql数据库–>
<dependency>
<groupId>mysql</groupId>
<artifactId>mysql-connector-java</artifactId>
</dependency>
<!– mybatis-plus依赖,用于使用mybatis-plus–>
<dependency>
<groupId>com.baomidou</groupId>
<artifactId>mybatis-plus-spring-boot3-starter</artifactId>
</dependency>
<!– pool2和druid依赖,用于mysql连接池–>
<dependency>
<groupId>org.apache.commons</groupId>
<artifactId>commons-pool2</artifactId>
</dependency>
<dependency>
<groupId>com.alibaba</groupId>
<artifactId>druid-spring-boot-starter</artifactId>
</dependency>
<!– 解决java.time.Duration序列化问题–>
<dependency>
<groupId>com.fasterxml.jackson.datatype</groupId>
<artifactId>jackson-datatype-jsr310</artifactId>
</dependency>
<!– 解决jacketjson序列化包 –>
<dependency>
<groupId>org.springframework.security</groupId>
<artifactId>spring-security-cas</artifactId>
</dependency>
<dependency>
<groupId>com.alibaba</groupId>
<artifactId>fastjson</artifactId>
</dependency>
<dependency>
<groupId>io.jsonwebtoken</groupId>
<artifactId>jjwt</artifactId>
</dependency>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-data-redis</artifactId>
</dependency>
<dependency>
<groupId>javax.xml.bind</groupId>
<artifactId>jaxb-api</artifactId>
</dependency>
</dependencies>

2)在resources包下面,创建yaml配置

server:
port: 9000

logging:
level:
org.springframework.security: trace

spring:
security:
# 使用security配置授权服务器的登录用户和密码
user:
name: user
password: 1234

# 配置数据源
datasource:
type: com.alibaba.druid.pool.DruidDataSource
driver-class-name: com.mysql.cj.jdbc.Driver
url: jdbc:mysql://127.0.0.1:3306/oauth_study?serverTimezone=Asia/Shanghai&useUnicode=true&characterEncoding=utf-8&zeroDateTimeBehavior=convertToNull&useSSL=false&allowPublicKeyRetrieval=true
username: root
password: root
druid:
initial-size: 5
min-idle: 5
maxActive: 20
maxWait: 3000
timeBetweenEvictionRunsMillis: 60000
minEvictableIdleTimeMillis: 300000
validationQuery: select 'x'
testWhileIdle: true
testOnBorrow: false
testOnReturn: false
poolPreparedStatements: false
filters: stat,wall,slf4j
connectionProperties: druid.stat.mergeSql=true;druid.stat.slowSqlMillis=5000;socketTimeout=10000;connectTimeout=1200

#redis配置
data:
redis:
host: 127.0.0.1

# mybatis-plus的配置
mybatis-plus:
global-config:
banner: false
mapper-locations: classpath:mappers/*.xml
type-aliases-package: com.demo.lesson06.entity
# 将handler包下的TypeHandler注册进去
type-handlers-package: com.demo.lesson06.handler
configuration:
cache-enabled: false
local-cache-scope: statement

3)在result包下面,新建IResultCode、Result和ResultCode用于统一处理前后端返回数据

public interface IResultCode {
String getCode();

String getMsg();
}

@JsonInclude(JsonInclude.Include.NON_NULL)
public class Result<T> implements Serializable {
private String code;
private T data;
private String msg;
private long total;

public static <T> Result<T> success() {
return success((T)null);
}

public static <T> Result<T> success(T data) {
ResultCode rce = ResultCode.SUCCESS;
if (data instanceof Boolean && Boolean.FALSE.equals(data)) {
rce = ResultCode.SYSTEM_EXECUTION_ERROR;
}

return result(rce, data);
}

public static <T> Result<T> success(T data, Long total) {
Result<T> result = new Result();
result.setCode(ResultCode.SUCCESS.getCode());
result.setMsg(ResultCode.SUCCESS.getMsg());
result.setData(data);
result.setTotal(total);
return result;
}

public static <T> Result<T> failed() {
return result(ResultCode.SYSTEM_EXECUTION_ERROR.getCode(), ResultCode.SYSTEM_EXECUTION_ERROR.getMsg(), (T)null);
}

public static <T> Result<T> failed(String msg) {
return result(ResultCode.SYSTEM_EXECUTION_ERROR.getCode(), msg, (T)null);
}

public static <T> Result<T> judge(boolean status) {
return status ? success() : failed();
}

public static <T> Result<T> failed(IResultCode resultCode) {
return result(resultCode.getCode(), resultCode.getMsg(), (T)null);
}

public static <T> Result<T> failed(IResultCode resultCode, String msg) {
return result(resultCode.getCode(), msg, (T)null);
}

private static <T> Result<T> result(IResultCode resultCode, T data) {
return result(resultCode.getCode(), resultCode.getMsg(), data);
}

private static <T> Result<T> result(String code, String msg, T data) {
Result<T> result = new Result();
result.setCode(code);
result.setData(data);
result.setMsg(msg);
return result;
}

public static boolean isSuccess(Result<?> result) {
return result != null && ResultCode.SUCCESS.getCode().equals(result.getCode());
}

public Result() {
}

public String getCode() {
return this.code;
}

public T getData() {
return this.data;
}

public String getMsg() {
return this.msg;
}

public long getTotal() {
return this.total;
}

public void setCode(String code) {
this.code = code;
}

public void setData(T data) {
this.data = data;
}

public void setMsg(String msg) {
this.msg = msg;
}

public void setTotal(long total) {
this.total = total;
}

public boolean equals(Object o) {
if (o == this) {
return true;
} else if (!(o instanceof Result)) {
return false;
} else {
Result<?> other = (Result)o;
if (!other.canEqual(this)) {
return false;
} else if (this.getTotal() != other.getTotal()) {
return false;
} else {
label49: {
Object this$code = this.getCode();
Object other$code = other.getCode();
if (this$code == null) {
if (other$code == null) {
break label49;
}
} else if (this$code.equals(other$code)) {
break label49;
}

return false;
}

Object this$data = this.getData();
Object other$data = other.getData();
if (this$data == null) {
if (other$data != null) {
return false;
}
} else if (!this$data.equals(other$data)) {
return false;
}

Object this$msg = this.getMsg();
Object other$msg = other.getMsg();
if (this$msg == null) {
if (other$msg != null) {
return false;
}
} else if (!this$msg.equals(other$msg)) {
return false;
}

return true;
}
}
}

protected boolean canEqual(Object other) {
return other instanceof Result;
}

public int hashCode() {
int result = 1;
long $total = this.getTotal();
result = result * 59 + (int)($total >>> 32 ^ $total);
Object $code = this.getCode();
result = result * 59 + ($code == null ? 43 : $code.hashCode());
Object $data = this.getData();
result = result * 59 + ($data == null ? 43 : $data.hashCode());
Object $msg = this.getMsg();
result = result * 59 + ($msg == null ? 43 : $msg.hashCode());
return result;
}

public String toString() {
return "Result(code=" + this.getCode() + ", data=" + this.getData() + ", msg=" + this.getMsg() + ", total=" + this.getTotal() + ")";
}
}

public enum ResultCode implements IResultCode, Serializable {
SUCCESS("00000", "ok"),
USER_ERROR("A0001", "用户信息为空"),
PARAM_IS_NULL("A0410", "请求必填参数为空"),
SYSTEM_EXECUTION_ERROR("B0001", "系统执行出错");

private String code;
private String msg;

public String getCode() {
return this.code;
}

public String getMsg() {
return this.msg;
}

public String toString() {
return "{\\"code\\":\\"" + this.code + '"' + ", \\"msg\\":\\"" + this.msg + '"' + '}';
}

public static ResultCode getValue(String code) {
ResultCode[] var1 = values();
int var2 = var1.length;

for(int var3 = 0; var3 < var2; ++var3) {
ResultCode value = var1[var3];
if (value.getCode().equals(code)) {
return value;
}
}

return SYSTEM_EXECUTION_ERROR;
}

private ResultCode(String code, String msg) {
this.code = code;
this.msg = msg;
}

private ResultCode() {
}
}

3 Spring Security配置

本次使用Spring Security做用户认证:

  • 基于数据库的用户认证,因此会创建t_user表
  • 关闭默认登录页面,使用自定义登录接口
  • 使用JWT返回token,不使用session,因此会先看到的token其实是Spring Security登录的token,并不是授权服务器的access_token

这部分如果不熟悉,可以参考我之前写的《Spring Security前后端分离》的文章

1)在config包下面,SecurityConfig类配置Spring Security相关内容

/**
* Spring Security配置
*/

@Configuration
@EnableWebSecurity
public class SecurityConfig {

@Autowired
private UserDetailsService userDetailsService;

@Autowired
private JwtAuthenticationTokenFilter jwtAuthenticationTokenFilter;

@Bean
public AuthenticationManager authenticationManager(){
// 配置合适的AuthenticationProvider
DaoAuthenticationProvider daoAuthenticationProvider = new DaoAuthenticationProvider();
// 为AuthenticationProvider设置UserDetailsService
daoAuthenticationProvider.setUserDetailsService(userDetailsService);
// 创建AuthenticationManager
return new ProviderManager(daoAuthenticationProvider);
}

@Bean
@Order(SecurityProperties.BASIC_AUTH_ORDER)
public SecurityFilterChain securityFilterChain(HttpSecurity http) throws Exception {
http.authorizeHttpRequests(auth->auth
//允许/login访问
.requestMatchers("/login").permitAll().anyRequest().authenticated())
// 禁用csrf,因为有post请求
.csrf(AbstractHttpConfigurer::disable)
// 添加到顾虑去链路中,确保在AuthorizationFilter过滤器之前
.addFilterBefore(jwtAuthenticationTokenFilter, AuthorizationFilter.class)
// 由于采用token方式认证,因此可以关闭session管理
.sessionManagement(SessionManagementConfigurer::disable)
// 禁用原来登录页面
.formLogin(AbstractHttpConfigurer::disable)
// 禁用系统原有的登出
.logout(LogoutConfigurer::disable)
// 异常处理
.exceptionHandling(exception -> exception.authenticationEntryPoint(new AuthenticationEntryPoint(){
@Override
public void commence(HttpServletRequest request, HttpServletResponse response, AuthenticationException authException) throws IOException, ServletException {
authException.printStackTrace();
Result<String> result = Result.failed("认证失败请重新登录");
String json = JSON.toJSONString(result);
response.setContentType("application/json;charset=utf-8");
response.getWriter().println(json);
}
}));
return http.build();
}
}

2)在utils包下,配置JwtUtil作为Spring Security生成JWT的token工具

/**
* JWT工具类
*/

public class JwtUtil {

//有效期为
public static final Long JWT_TTL = 60 * 60 * 1000L; // 60 * 60 *1000 一个小时
//设置秘钥明文
public static final String JWT_KEY = "moo";

public static String getUUID(){
return UUID.randomUUID().toString().replaceAll("-", "");
}

/**
* 创建token
*/

public static String createToken(String subject) {
return getJwtBuilder(subject, null, getUUID());// 设置过期时间
}

/**
* 创建token
*/

public static String createToken(String subject, Long ttlMillis) {
return getJwtBuilder(subject, ttlMillis, getUUID());// 设置过期时间
}

/**
* 创建token
*/

public static String createToken(String id, String subject, Long ttlMillis) {
return getJwtBuilder(subject, ttlMillis, id);// 设置过期时间
}

/**
* 解析token
*/

public static String parseJWT(String token) throws Exception {
SecretKey secretKey = generalKey();
return Jwts.parser()
.setSigningKey(secretKey)
.parseClaimsJws(token)
.getBody().getSubject();
}

private static String getJwtBuilder(String subject, Long ttlMillis, String uuid) {
SignatureAlgorithm signatureAlgorithm = SignatureAlgorithm.HS256;
SecretKey secretKey = generalKey();
long nowMillis = System.currentTimeMillis();
Date now = new Date(nowMillis);
if(ttlMillis==null){
ttlMillis=JwtUtil.JWT_TTL;
}
long expMillis = nowMillis + ttlMillis;
Date expDate = new Date(expMillis);
return Jwts.builder()
.setId(uuid) //唯一的ID
.setSubject(subject) // 主题 可以是JSON数据
.setIssuer(JWT_KEY) // 签发者
.setIssuedAt(now) // 签发时间
.signWith(signatureAlgorithm, secretKey) //使用HS256对称加密算法签名, 第二个参数为秘钥
.setExpiration(expDate)
.compact();
}

/**
* 生成加密后的秘钥 secretKey
*/

private static SecretKey generalKey() {
byte[] encodedKey = Base64.getDecoder().decode(JwtUtil.JWT_KEY);
SecretKey key = new SecretKeySpec(encodedKey, 0, encodedKey.length, "AES");
return key;
}

3)在jwt包下,定义jwt过滤器,用于替换Spring Security默认的session

/**
* 用于Spring Security集成JWT认证
*/

@Component
public class JwtAuthenticationTokenFilter extends OncePerRequestFilter {

@Autowired
private RedisTemplate redisTemplate;

@Override
protected void doFilterInternal(HttpServletRequest request, HttpServletResponse response, FilterChain filterChain) throws ServletException, IOException {
// 过滤login接口
if("/login".equals(request.getRequestURI())){
filterChain.doFilter(request, response);
return;
}

// 从请求头获取token
String token = request.getHeader("access_token");
// 检查获取到的token是否为空或空白字符串。(判断给定的字符串是否包含文本)
if (!StringUtils.hasText(token)) {
// 如果token为空,则直接放行请求到下一个过滤器,不做进一步处理并结束当前方法,不继续执行下面代码。
filterChain.doFilter(request, response);
return;
}
// 解析token
String userAccount;
try {
userAccount = JwtUtil.parseJWT(token);
} catch (Exception e) {
filterChain.doFilter(request, response);
return;
}
// 临时缓存中 获取 键 对应 数据
Object object = redisTemplate.opsForValue().get(userAccount);
LoginUserDetails loginUser = (LoginUserDetails)object;
if (Objects.isNull(loginUser)) {
filterChain.doFilter(request, response);
return;
}
// 将用户信息存入 SecurityConText
// UsernamePasswordAuthenticationToken 存储用户名 密码 权限的集合
UsernamePasswordAuthenticationToken authenticationToken = new UsernamePasswordAuthenticationToken(loginUser, null, null);
// SecurityContextHolder是Spring Security用来存储当前线程安全的认证信息的容器。
// 将用户名 密码 权限的集合存入SecurityContextHolder
SecurityContextHolder.getContext().setAuthentication(authenticationToken);
// 放行
filterChain.doFilter(request, response);
}
}

4)在entity包下新增TUser类,mapper包下新增TUserMapper,用于数据库查询t_user表

/**
* 表user结构
*/

@Data
public class TUser implements Serializable {

@TableId(type = IdType.ASSIGN_ID)
private Long id;

private String username;

private String password;

private String email;

private String phone;

}

@Mapper
public interface TUserMapper {

// 根据用户名,查询用户信息
@Select("select * from t_user where username = #{username}")
TUser selectByUsername(String username);

}

5)在entity包下新建LoginUserDetails,在service包下新建UserDetailsServiceImpl,用于覆盖Spring Security原先的查询用户接口

/**
* 扩展Spring Security的UserDetails
*/

@Data
@NoArgsConstructor
@AllArgsConstructor
@JsonIgnoreProperties(ignoreUnknown = true)
public class LoginUserDetails implements UserDetails {

private TUser tUser;

@Override
@JsonIgnore
public Collection<? extends GrantedAuthority> getAuthorities() {
return List.of();
}

@Override
public String getPassword() {
return tUser.getPassword();
}

@Override
public String getUsername() {
return tUser.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;
}
}

@Service
public class UserDetailsServiceImpl implements UserDetailsService {

@Autowired
private TUserMapper tUserMapper;

@Override
public UserDetails loadUserByUsername(String username) throws UsernameNotFoundException {
// 查询自己数据库的用户信息
TUser user = tUserMapper.selectByUsername(username);
if(user == null){
throw new UsernameNotFoundException(username);
}
return new LoginUserDetails(user);
}
}

6)在config包下,对Redis进行配置

@Configuration
public class RedisConfiguration {

/**
* 主要做redis配置。redis有2种不同的template(2种的key不能共享)
* 1.StringRedisTemplate:以String作为存储方式:默认使用StringRedisTemplate,其value都是以String方式存储
* 2.RedisTemplate:
* 1)使用默认RedisTemplate时,其value都是根据jdk序列化的方式存储
* 2)自定义Jackson2JsonRedisSerializer序列化,以json格式存储,其key与StringRedisTemplate共享,返回值是LinkedHashMap
* 3)自定义GenericJackson2JsonRedisSerializer序列化,以json格式存储,其key与StringRedisTemplate共享,返回值是原先对象(因为保存了classname)
*/

@Bean
@ConditionalOnMissingBean({RedisTemplate.class})
public RedisTemplate redisTemplate(RedisConnectionFactory factory) {
RedisTemplate<String, Object> template = new RedisTemplate();
template.setConnectionFactory(factory);
//本实例采用GenericJackson2JsonRedisSerializer
ObjectMapper om = new ObjectMapper();
om.setVisibility(PropertyAccessor.ALL, JsonAutoDetect.Visibility.ANY);
GenericJackson2JsonRedisSerializer jackson2JsonRedisSerializer = new GenericJackson2JsonRedisSerializer();
StringRedisSerializer stringRedisSerializer = new StringRedisSerializer();
template.setKeySerializer(stringRedisSerializer);
template.setHashKeySerializer(stringRedisSerializer);
template.setValueSerializer(jackson2JsonRedisSerializer);
template.setHashValueSerializer(jackson2JsonRedisSerializer);
template.afterPropertiesSet();
return template;
}

@Bean
@ConditionalOnMissingBean({StringRedisTemplate.class})
public StringRedisTemplate stringRedisTemplate(RedisConnectionFactory factory) {
StringRedisTemplate template = new StringRedisTemplate();
template.setConnectionFactory(factory);
return template;
}

}

7)在dto包线新建LoginDTO,在service包下新建LoginService及实现类LoginServiceImpl,在controller包下新建LoginController,用于自定义登录和登出

/**
* 前后端参数
*/

@Data
@AllArgsConstructor
@NoArgsConstructor
public class LoginDTO {

private String username;
private String password;
}

public interface LoginService {

Result<String> login(LoginDTO loginDTO);

Result<String> logout();
}

@Service
public class LoginServiceImpl implements LoginService {

@Autowired
private AuthenticationManager authenticationManager;

@Autowired
private RedisTemplate redisTemplate;

private static String PRE_KEY = "user:";

@Override
public Result<String> login(LoginDTO loginDTO) {
String username = loginDTO.getUsername();
String password = loginDTO.getPassword();
UsernamePasswordAuthenticationToken authenticationToken = UsernamePasswordAuthenticationToken.unauthenticated(username, password);
try {
Authentication authentication = authenticationManager.authenticate(authenticationToken);
if(authentication!=null && authentication.isAuthenticated()){
SecurityContextHolder.getContext().setAuthentication(authentication);
LoginUserDetails user = (LoginUserDetails)authentication.getPrincipal();
String subject = PRE_KEY + user.getTUser().getId();
String token = JwtUtil.createToken(subject, 1000*60*5L);
redisTemplate.opsForValue().set(subject, user, 1000*60*5L, TimeUnit.MILLISECONDS);
return Result.success(token);
}
}catch (AuthenticationException e){
return Result.failed(e.getLocalizedMessage());
}
catch (Exception e){
e.printStackTrace();
}
return Result.failed("认证失败");
}

@Override
public Result<String> logout() {
if(SecurityContextHolder.getContext().getAuthentication()!=null){
LoginUserDetails loginUserDetails = (LoginUserDetails)SecurityContextHolder.getContext().getAuthentication().getPrincipal();
if(loginUserDetails!=null){
String key = PRE_KEY + loginUserDetails.getTUser().getId();
redisTemplate.delete(key);
}else {
return Result.failed("登出失败,用户不存在");
}
}
return Result.success("登出成功");
}
}

/**
* 重新定义Spring Security的登录接口
*/

@RestController
public class LoginController {

@Autowired
private LoginService loginService;

@PostMapping("/login")
public Result<String> login(@RequestBody LoginDTO loginDTO) {
return loginService.login(loginDTO);
}

@PostMapping("/logout")
public Result<String> logout() {
return loginService.logout();
}
}

8)至此,你可以启动服务,并测试/login和/logout接口,可以获得登录的token(注意:该token是Spring Security登录的token,并不是授权服务器的access_token)

在这里插入图片描述

4 授权服务器配置

授权服务器的配置

  • 不做授权页面,使用接口直接返回授权信息
  • 由于Spring Security是采用JWT的token认证登录,因此需要将授权服务器的SecurityContextRepository重写获取redis中的数据
  • 自定义基于数据库的客户端
  • 自定义token的加密(这个密钥来自系列九中的demo.jks)

1)在config包下,定义授权服务器配置

/**
* 授权服务器配置
*/

@Configuration
public class AuthServerConfig {

@Autowired
private RedisTemplate redisTemplate;

// 自定义授权服务器的Filter链
@Bean
@Order(Ordered.HIGHEST_PRECEDENCE)
SecurityFilterChain authorizationServerSecurityFilterChain(HttpSecurity http) throws Exception {
OAuth2AuthorizationServerConfiguration.applyDefaultSecurity(http);

http.getConfigurer(OAuth2AuthorizationServerConfigurer.class)
// oidc配置
.oidc(withDefaults())
// 自定义授权页面接口
.authorizationEndpoint(auth -> auth.consentPage("/consentface"))
;
// 注入Redis获取登录用户信息,因为Spring Security使用的是jwt+redis存储,因此原先基于Session的不可使用
http.securityContext(c -> c.securityContextRepository(new RedisSecurityContextRepository(redisTemplate)));
// 资源服务器默认jwt配置
http.oauth2ResourceServer((resourceServer) -> resourceServer.jwt(withDefaults()));
// 异常处理
http.exceptionHandling(exception -> exception.authenticationEntryPoint(new AuthenticationEntryPoint(){
@Override
public void commence(HttpServletRequest request, HttpServletResponse response, AuthenticationException authException) throws IOException, ServletException {
authException.printStackTrace();
Result<String> result = Result.failed("认证失败请重新登录");
String json = JSON.toJSONString(result);
response.setContentType("application/json;charset=utf-8");
response.getWriter().println(json);
}
}));
return http.build();
}

/**
* 访问令牌签名
*/

@Bean
public JWKSource<SecurityContext> jwkSource() {
KeyPair keyPair = generateRsaKey();
RSAPublicKey publicKey = (RSAPublicKey) keyPair.getPublic();
RSAPrivateKey privateKey = (RSAPrivateKey) keyPair.getPrivate();
RSAKey rsaKey = new RSAKey.Builder(publicKey)
.privateKey(privateKey)
.keyID(UUID.randomUUID().toString())
.build();
JWKSet jwkSet = new JWKSet(rsaKey);
return new ImmutableJWKSet<>(jwkSet);
}

/**
* 其 key 在启动时生成,用于创建上述 JWKSource
*/

private static KeyPair generateRsaKey() {
KeyStoreKeyFactory factory = new KeyStoreKeyFactory(new ClassPathResource("demo.jks"), "linmoo".toCharArray());
KeyPair keyPair = factory.getKeyPair("demo", "linmoo".toCharArray());
return keyPair;
}
}

2)自定义基于数据库的客户端,这部分和lesson05子模块一样,这里就不贴代码,主要包括以下部分:

  • entity包下自定义的SelfOAuth2Authorization、SelfOAuth2AuthorizationConsent和SelfRegisteredClient
  • handler包下自定义的ClientSettingsTypeHandler、SetStringTypeHandler、TokenMetadataTypeHandler和TokenSettingsTypeHandler。注意:由于oauth2_authorization表的attributes字段会存入Spring Security的用户信息,因此需要将TUser支持Jackson序列化,因此在TokenMetadataTypeHandler中注入TUser类,详情见代码
  • mapper包下自定义OAuth2AuthorizationConsentMapper、OAuth2AuthorizationMapper和Oauth2RegisteredClientMapper
  • repository包下自定义SelfJdbcOAuth2AuthorizationService、SelfJdbcRegisteredClientRepository和SeltJdbcOAuth2AuthorizationConsentService

3)在redis包下,自定义SecurityContextRepository,用于授权服务器读取Redis用户信息

/**
* 自定义的Context
*/

public class MySupplierDeferredSecurityContext implements DeferredSecurityContext {

private static final Log logger = LogFactory.getLog(MySupplierDeferredSecurityContext.class);

private final Supplier<SecurityContext> supplier;

private final SecurityContextHolderStrategy strategy;

private SecurityContext securityContext;

private boolean missingContext;

public MySupplierDeferredSecurityContext(Supplier<SecurityContext> supplier, SecurityContextHolderStrategy strategy) {
this.supplier = supplier;
this.strategy = strategy;
}

@Override
public SecurityContext get() {
init();
return this.securityContext;
}

@Override
public boolean isGenerated() {
init();
return this.missingContext;
}

private void init() {
if (this.securityContext != null) {
return;
}

this.securityContext = this.supplier.get();
this.missingContext = (this.securityContext == null);
if (this.missingContext) {
this.securityContext = this.strategy.createEmptyContext();
if (logger.isTraceEnabled()) {
logger.trace(LogMessage.format("Created %s", this.securityContext));
}
}
}

}

/**
* 授权服务器读取存储在Redis的用户信息,可以做为用户认证
*/

public class RedisSecurityContextRepository implements SecurityContextRepository {

private RedisTemplate redisTemplate;

public RedisSecurityContextRepository(RedisTemplate redisTemplate) {
this.redisTemplate = redisTemplate;
}

@Override
public SecurityContext loadContext(HttpRequestResponseHolder requestResponseHolder) {
HttpServletRequest request = requestResponseHolder.getRequest();
return readSecurityContextFromRedis(request);
}

@Override
public DeferredSecurityContext loadDeferredContext(HttpServletRequest request) {
Supplier<SecurityContext> supplier = () -> readSecurityContextFromRedis(request);
return new MySupplierDeferredSecurityContext(supplier, SecurityContextHolder.getContextHolderStrategy());
}

@Override
public void saveContext(SecurityContext context, HttpServletRequest request, HttpServletResponse response) {

}

@Override
public boolean containsContext(HttpServletRequest request) {
return false;
}

private SecurityContext readSecurityContextFromRedis(HttpServletRequest request) {
// 从请求头获取token
String token = request.getHeader("access_token");
// 检查获取到的token是否为空或空白字符串。(判断给定的字符串是否包含文本)
if (!StringUtils.hasText(token)) {
// 如果token为空,则直接放行请求到下一个过滤器,不做进一步处理并结束当前方法,不继续执行下面代码。
return null;
}
// 解析token
String userAccount;
try {
userAccount = JwtUtil.parseJWT(token);
} catch (Exception e) {
throw new AccessDeniedException("token格式有误");
}
// 临时缓存中 获取 键 对应 数据
Object object = redisTemplate.opsForValue().get(userAccount);
LoginUserDetails loginUser = (LoginUserDetails)object;
if (Objects.isNull(loginUser)) {
throw new AccessDeniedException("用户未登录");
}
SecurityContextHolderStrategy securityContextHolderStrategy = SecurityContextHolder.getContextHolderStrategy();
SecurityContext securityContext = securityContextHolderStrategy.createEmptyContext();
// 将用户信息存入 SecurityConText
// UsernamePasswordAuthenticationToken 存储用户名 密码 权限的集合
UsernamePasswordAuthenticationToken authenticationToken = new UsernamePasswordAuthenticationToken(loginUser, null, null);
// SecurityContextHolder是Spring Security用来存储当前线程安全的认证信息的容器。
// 将用户名 密码 权限的集合存入SecurityContextHolder
securityContext.setAuthentication(authenticationToken);
return securityContext;
}
}

4)在entity包下新建ConsentDTO,在controller包下新建ConsentController,用于返回授权信息给前端

/**
* 前后端参数,授权信息
*/

@Data
@AllArgsConstructor
@NoArgsConstructor
public class ConsentDTO {

private String clientId;

private String clientName;

private String state;

private Set<String> scopes;

private String principalName;

private String redirectUri;
}

//自定义授权页面接口,返回state数据,不返回页面,由前端去组装页面
@RestController
public class ConsentController {

@Autowired
private RegisteredClientRepository registeredClientRepository;

@GetMapping(value = "/consentface")
public Result<ConsentDTO> consent(Principal principal, Model model,
@RequestParam(OAuth2ParameterNames.CLIENT_ID) String clientId,
@RequestParam(OAuth2ParameterNames.STATE) String state) {

RegisteredClient registeredClient = this.registeredClientRepository.findByClientId(clientId);
Set<String> scopes = registeredClient.getScopes();
ConsentDTO consentDTO = new ConsentDTO();
consentDTO.setClientId(clientId);
consentDTO.setClientName(registeredClient.getClientName());
consentDTO.setState(state);
consentDTO.setScopes(scopes);
consentDTO.setPrincipalName(principal.getName());
consentDTO.setRedirectUri(registeredClient.getRedirectUris().iterator().next());
return Result.success(consentDTO);
}
}

5)到此我们的授权服务器配置完成,唯一与lesson05子模块不同之处在于自定义了SecurityContextRepository

5 测试

1)登录,此处获得登录的token,后续请求授权界面可用。 在这里插入图片描述

2)确认授权请求,其headers中的access_token是Spring Security登录的token,在步骤1)中得到的。这时候会返回state参数,为下一步获取授权码使用

在这里插入图片描述

3)获得授权码code

  • 关闭自动跳转

在这里插入图片描述

  • 设置登录的token,其headers中的access_token是Spring Security登录的token,在步骤1)中得到的。

在这里插入图片描述

  • 设置Body参数(其中state来自步骤2的state)以及URL,并请求,在返回的headers中的Location得到授权码code

在这里插入图片描述

4)获取access_token

  • 在Authorization 设置授权服务器的客户端信息

在这里插入图片描述

  • 配置body(其中code来自步骤3)的授权码code),并请求,就可以得到access_token信息

在这里插入图片描述

结语:至此,我们演示了一个前后端分离的授权服务器的。到目前为止,我们关于授权服务器的使用就告一段落,当然还有很多功能没有讲,这个在后面的系列会逐一讲解,下一章我们来看看资源服务器。

赞(0)
未经允许不得转载:网硕互联帮助中心 » 基于Spring Security 6的OAuth2 系列之十一 - 授权服务器--前后端分离授权服务器
分享到: 更多 (0)

评论 抢沙发

评论前必须登录!