基于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

前面从《系列之五 - 授权服务器–开篇》到《授权服务器–刷新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信息

在这里插入图片描述

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

评论 12
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包

打赏作者

linmoo1986

你的鼓励将是我创作的最大动力

¥1 ¥2 ¥4 ¥6 ¥10 ¥20
扫码支付:¥1
获取中
扫码支付

您的余额不足,请更换扫码支付或充值

打赏作者

实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值