Springboot3+SpringSecurity6Oauth2+vue3前后端分离认证授权-授权服务

发布于:2025-09-04 ⋅ 阅读:(17) ⋅ 点赞:(0)

整体流程

客户端前端 客户端后端 授权服务前端 授权服务后端 资源服务后端 请求/hello接口 无权限返回code=1001 跳转到登录页 请求登录/login接口 返回授权服务获取授权码页面地址 跳转到获取授权码页面 请求获取授权码/oauth2/authorize接口 无权限返回code=1001 跳转到登录页 请求登录/login接口验证用户密码 登录成功返回token 跳转回获取授权码页面 带token请求获取授权码/oauth2/authorize接口 返回授权码和客户端回调地址(带token) 跳转到客户端回调地址(带token) 请求回调/callback接口 带token请求获取access_token的/oauth2/token接口 返回access_token 返回access_token 跳转回最初始地址/ 带access_token请求/hello接口 带access_token请求/authentication接口 返回认证授权信息Authentication 带Authentication走接下来流程 返回/hello接口结果 客户端前端 客户端后端 授权服务前端 授权服务后端 资源服务后端

前端

技术栈

vue3+vite4+axios+pinia+naiveui

项目结构

在这里插入图片描述

代码

vite.config.ts

import { fileURLToPath, URL } from 'node:url'

import { defineConfig } from 'vite'
import vue from '@vitejs/plugin-vue'

// https://vitejs.dev/config/
export default defineConfig({
  plugins: [
    vue(),
  ],
  resolve: {
    alias: {
      '@': fileURLToPath(new URL('./src', import.meta.url))
    }
  },
  server: {
    port: 3001,
    open: false,
    proxy: {
      '/api': {
        changeOrigin: true,
        target: "http://localhost:8081",
        rewrite: (p) => p.replace(/^\/api/, '')
      }
    }
  }
})

Code.vue

<script setup lang="ts">
import axios from 'axios'
import { useRoute, useRouter } from 'vue-router'

const route = useRoute()
const router = useRouter()

function init() {
  const token = route.query.token
  axios.get('/api/oauth2/authorize', {
    params: route.query,
    headers: {
      'token': token
    }
  })
    .then(r => {
      let data = r.data
      if (data && data.code && data.code == 1001) {
        router.push(`/login?back=${encodeURIComponent(route.fullPath)}`)
      }
      // 返回的客户端回调地址
      let location = r.headers.get('Location')
      if (location) {
        location += '&token=' + token + '&back=' + route.query.back
        window.location.href = location
      }
    })
    .catch(e => {
      console.error(e)
    })
}

init()
</script>

<template>
</template>

Login.vue

<script setup lang="ts">
import { NForm, NFormItem, NButton, NInput } from 'naive-ui'
import { ref } from 'vue'
import axios from 'axios'
import { useRoute, useRouter } from 'vue-router'

const route = useRoute()
const router = useRouter()
const formValue = ref({"username": '', "password": ''})
const m = ref('')

function handleValidateClick(e: MouseEvent) {
  axios.post('/api/login', formValue.value)
    .then(r => {
      let data = r.data
      if (data && data.code && data.code == 1001) {
        m.value = data.msg
      } else {
        let back = route.query.back + '&token=' + data
        router.push(back)
      }
    })
    .catch(e => {
      console.error(e)
    })
}
</script>

<template>
  <main>
    <div>
      <n-form inline :label-width="80" :model="formValue">
        <n-form-item label="姓名">
          <n-input v-model:value="formValue.username" placeholder="输入姓名" />
        </n-form-item>
        <n-form-item label="年龄">
          <n-input v-model:value="formValue.password" placeholder="输入年龄" />
        </n-form-item>
        <n-form-item>
          <n-button attr-type="button" @click="handleValidateClick">
            验证
          </n-button>
        </n-form-item>
      </n-form>
    </div>
    <div>
      {{ m }}
    </div>
  </main>
</template>

后端

技术栈

springboot3
spring security6 oauth2

项目结构

在这里插入图片描述

代码

pom.xml

<?xml version="1.0" encoding="UTF-8"?>
<project xmlns="http://maven.apache.org/POM/4.0.0"
         xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
         xsi:schemaLocation="http://maven.apache.org/POM/4.0.0 http://maven.apache.org/xsd/maven-4.0.0.xsd">
    <modelVersion>4.0.0</modelVersion>
    <parent>
        <groupId>org.example</groupId>
        <artifactId>security</artifactId>
        <version>1.0-SNAPSHOT</version>
    </parent>

    <artifactId>security-server</artifactId>

    <properties>
        <maven.compiler.source>17</maven.compiler.source>
        <maven.compiler.target>17</maven.compiler.target>
        <project.build.sourceEncoding>UTF-8</project.build.sourceEncoding>
    </properties>

    <dependencies>
        <dependency>
            <groupId>org.springframework.boot</groupId>
            <artifactId>spring-boot-starter-web</artifactId>
        </dependency>
        <dependency>
            <groupId>org.springframework.boot</groupId>
            <artifactId>spring-boot-starter-security</artifactId>
        </dependency>
        <dependency>
            <groupId>org.springframework.security</groupId>
            <artifactId>spring-security-oauth2-authorization-server</artifactId>
        </dependency>

        <dependency>
            <groupId>com.alibaba.fastjson2</groupId>
            <artifactId>fastjson2</artifactId>
        </dependency>
    </dependencies>

</project>

application.yml

logging:
  level:
    org.springframework.security: TRACE

server:
  port: 8081

AuthorizationServerConfig.java

package org.example.server.config;

import com.alibaba.fastjson2.JSON;
import com.nimbusds.jose.jwk.JWKSet;
import com.nimbusds.jose.jwk.RSAKey;
import com.nimbusds.jose.jwk.source.JWKSource;
import com.nimbusds.jose.proc.SecurityContext;
import org.example.server.security.MapSecurityContextRepository;
import org.example.server.security.OkAuthenticationSuccessHandler;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.core.Ordered;
import org.springframework.core.annotation.Order;
import org.springframework.security.authentication.AuthenticationManager;
import org.springframework.security.config.Customizer;
import org.springframework.security.config.annotation.web.builders.HttpSecurity;
import org.springframework.security.crypto.password.PasswordEncoder;
import org.springframework.security.oauth2.core.AuthorizationGrantType;
import org.springframework.security.oauth2.core.ClientAuthenticationMethod;
import org.springframework.security.oauth2.core.oidc.OidcScopes;
import org.springframework.security.oauth2.jwt.JwtDecoder;
import org.springframework.security.oauth2.server.authorization.client.InMemoryRegisteredClientRepository;
import org.springframework.security.oauth2.server.authorization.client.RegisteredClient;
import org.springframework.security.oauth2.server.authorization.client.RegisteredClientRepository;
import org.springframework.security.oauth2.server.authorization.config.annotation.web.configuration.OAuth2AuthorizationServerConfiguration;
import org.springframework.security.oauth2.server.authorization.config.annotation.web.configurers.OAuth2AuthorizationServerConfigurer;
import org.springframework.security.oauth2.server.authorization.settings.AuthorizationServerSettings;
import org.springframework.security.oauth2.server.authorization.settings.ClientSettings;
import org.springframework.security.oauth2.server.authorization.settings.TokenSettings;
import org.springframework.security.web.SecurityFilterChain;
import org.springframework.security.web.context.SecurityContextRepository;

import java.security.KeyPair;
import java.security.KeyPairGenerator;
import java.security.NoSuchAlgorithmException;
import java.security.interfaces.RSAPrivateKey;
import java.security.interfaces.RSAPublicKey;
import java.time.Duration;
import java.util.HashMap;
import java.util.Map;
import java.util.UUID;

/**
 * 授权服务配置
 * 
 * @author qiongying.huai
 * @version 1.0
 * @date 11:15 2025/6/23
 */
@Configuration
public class AuthorizationServerConfig {

    private final Logger logger = LoggerFactory.getLogger(getClass());

    @Bean
    @Order(Ordered.HIGHEST_PRECEDENCE)
    public SecurityFilterChain authorizationServerSecurityFilterChain(HttpSecurity http, AuthenticationManager authenticationManager) throws Exception {
        OAuth2AuthorizationServerConfigurer authorizationServer = OAuth2AuthorizationServerConfigurer.authorizationServer();
        http
                // 只匹配 OAuth2 授权服务器的相关端点
                .securityMatcher(authorizationServer.getEndpointsMatcher())
                // 启用 OpenID
                .with(authorizationServer, c -> c
                        .oidc(Customizer.withDefaults())
                        .authorizationEndpoint(a -> a
                                .authorizationResponseHandler(new OkAuthenticationSuccessHandler())))
                .authorizeHttpRequests(authorize -> authorize
                        .anyRequest().authenticated())
                .securityContext(c -> c.securityContextRepository(securityContextRepository()))
                .exceptionHandling(exceptions -> exceptions
                        .authenticationEntryPoint((request, response, authException) -> {
                            logger.error("request: {}, error: ", request.getRequestURI(), authException);
                            Map<String, Object> responseData = new HashMap<>(4);
                            responseData.put("code", 1001);
                            responseData.put("msg", authException.getMessage());
                            response.setContentType("application/json;charset=utf-8");
                            response.setStatus(200);
                            response.getWriter().write(JSON.toJSONString(responseData));
                        })
                )
                .oauth2ResourceServer(rs -> rs.jwt(Customizer.withDefaults()));

        return http.build();
    }

    @Bean
    public SecurityContextRepository securityContextRepository() {
        return new MapSecurityContextRepository();
    }

    @Bean
    public RegisteredClientRepository registeredClientRepository(PasswordEncoder passwordEncoder) {
        RegisteredClient client = RegisteredClient.withId(UUID.randomUUID().toString())
                .clientId("client1")
                .clientSecret(passwordEncoder.encode("secret"))
                .clientAuthenticationMethod(ClientAuthenticationMethod.CLIENT_SECRET_BASIC)
                .authorizationGrantType(AuthorizationGrantType.AUTHORIZATION_CODE)
                .authorizationGrantType(AuthorizationGrantType.REFRESH_TOKEN)
                .redirectUri("http://localhost:3003/callback")
                .scope("all")
                .scope("user_info")
                .scope(OidcScopes.OPENID)
                .scope(OidcScopes.PROFILE)
                // 客户端设置,设置用户需要确认授权,设置false后不需要确认
                .clientSettings(ClientSettings.builder()
                        .requireAuthorizationConsent(false).build())
                // 设置accessToken有效期
                .tokenSettings(TokenSettings.builder().accessTokenTimeToLive(Duration.ofSeconds(10)).build())
                .build();

        return new InMemoryRegisteredClientRepository(client);
    }

    @Bean
    public AuthorizationServerSettings authorizationServerSettings() {
        return AuthorizationServerSettings.builder().build();
    }

    @Bean
    public JWKSource<SecurityContext> jwkSource() {
        RSAKey rsaKey = generateRsa();
        JWKSet jwkSet = new JWKSet(rsaKey);
        return (jwkSelector, securityContext) -> jwkSelector.select(jwkSet);
    }

    @Bean
    public JwtDecoder jwtDecoder(JWKSource<SecurityContext> jwkSource) {
        // 会有60s的时间偏差: org.springframework.security.oauth2.jwt.JwtTimestampValidator.DEFAULT_MAX_CLOCK_SKEW
        return OAuth2AuthorizationServerConfiguration.jwtDecoder(jwkSource);
    }

    private RSAKey generateRsa() {
        KeyPair keyPair = generateRsaKey();
        RSAPublicKey publicKey = (RSAPublicKey) keyPair.getPublic();
        RSAPrivateKey privateKey = (RSAPrivateKey) keyPair.getPrivate();
        return new RSAKey.Builder(publicKey)
                .privateKey(privateKey)
                .keyID(UUID.randomUUID().toString())
                .build();
    }

    private KeyPair generateRsaKey() {
        try {
            KeyPairGenerator keyPairGenerator = KeyPairGenerator.getInstance("RSA");
            keyPairGenerator.initialize(2048);
            return keyPairGenerator.generateKeyPair();
        } catch (NoSuchAlgorithmException e) {
            throw new IllegalStateException(e);
        }
    }
}

SecurityConfig.java

package org.example.server.config;

import com.alibaba.fastjson2.JSON;
import org.example.server.security.TokenFilter;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.security.authentication.AuthenticationManager;
import org.springframework.security.config.Customizer;
import org.springframework.security.config.annotation.authentication.configuration.AuthenticationConfiguration;
import org.springframework.security.config.annotation.web.builders.HttpSecurity;
import org.springframework.security.config.annotation.web.configuration.EnableWebSecurity;
import org.springframework.security.config.annotation.web.configurers.AbstractHttpConfigurer;
import org.springframework.security.core.userdetails.User;
import org.springframework.security.core.userdetails.UserDetails;
import org.springframework.security.core.userdetails.UserDetailsService;
import org.springframework.security.crypto.bcrypt.BCryptPasswordEncoder;
import org.springframework.security.crypto.password.PasswordEncoder;
import org.springframework.security.provisioning.InMemoryUserDetailsManager;
import org.springframework.security.web.SecurityFilterChain;
import org.springframework.security.web.context.SecurityContextRepository;
import org.springframework.security.web.access.ExceptionTranslationFilter;

import java.util.HashMap;
import java.util.Map;

/**
 * Spring security配置
 *
 * @author qiongying.huai
 * @version 1.0
 * @date 11:23 2025/6/23
 */
@Configuration
@EnableWebSecurity
public class SecurityConfig {

    private final Logger logger = LoggerFactory.getLogger(getClass());

    private final SecurityContextRepository securityContextRepository;

    public SecurityConfig(SecurityContextRepository securityContextRepository) {
        this.securityContextRepository = securityContextRepository;
    }

    @Bean
    public SecurityFilterChain defaultSecurityFilterChain(HttpSecurity http) throws Exception {
        http
                .authorizeHttpRequests(authorize -> authorize
                        .requestMatchers("/error", "/login").permitAll()
                        .anyRequest().authenticated()
                )
                // 放在ExceptionTranslationFilter之后,自定义的filter中的异常才能被exceptionHandling中的自定义处理器处理
                .addFilterAfter(new TokenFilter(securityContextRepository), ExceptionTranslationFilter.class)
                .formLogin(AbstractHttpConfigurer::disable)
                // 前后端分离的,需要关闭CSRF保护,不然自定义的login接口403
                .csrf(AbstractHttpConfigurer::disable)
                .exceptionHandling(e ->
                        e.authenticationEntryPoint((request, response, authException) -> {
                            logger.error("request: {}, error: ", request.getRequestURI(), authException);
                            Map<String, Object> responseData = new HashMap<>(4);
                            responseData.put("code", 1001);
                            responseData.put("msg", authException.getMessage());
                            response.setContentType("application/json;charset=utf-8");
                            response.setStatus(200);
                            response.getWriter().write(JSON.toJSONString(responseData));
                        }));
        return http.build();
    }

    @Bean
    public UserDetailsService userDetailsService(PasswordEncoder passwordEncoder) {
        UserDetails user = User.withUsername("user")
                .password(passwordEncoder.encode("password"))
                // 不需要加ROLE_前缀
                .roles("USER")
                .build();
        return new InMemoryUserDetailsManager(user);
    }

    @Bean
    public PasswordEncoder passwordEncoder() {
        return new BCryptPasswordEncoder();
    }

    @Bean
    public AuthenticationManager authenticationManager(AuthenticationConfiguration authenticationConfiguration) throws Exception {
        return authenticationConfiguration.getAuthenticationManager();
    }
}

WebMvcConfig.java

package org.example.server.config;

import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.lang.NonNull;
import org.springframework.web.servlet.config.annotation.CorsRegistry;
import org.springframework.web.servlet.config.annotation.WebMvcConfigurer;

/**
 * 跨域配置
 *
 * @author qiongying.huai
 * @version 1.0
 * @date 14:26 2025/7/14
 */
@Configuration
public class WebMvcConfig {

    @Bean
    public WebMvcConfigurer corsConfigurer() {
        return new WebMvcConfigurer() {
            @Override
            public void addCorsMappings(@NonNull CorsRegistry registry) {
                registry.addMapping("/**");
            }
        };
    }
}

MapSecurityContextHolder.java

package org.example.server.security;

import org.springframework.security.core.context.SecurityContext;
import org.springframework.util.StringUtils;

import java.util.HashMap;
import java.util.Map;

/**
 * 简单的token本地缓存
 *
 * @author qiongying.huai
 * @version 1.0
 * @date 16:20 2025/7/19
 */
public class MapSecurityContextHolder {

    private static final Map<String, SecurityContext> CONTEXT_MAP = new HashMap<>();

    private MapSecurityContextHolder() {
    }

    public static SecurityContext getContext(String key) {
        return CONTEXT_MAP.get(key);
    }

    public static void addContext(String key, SecurityContext context) {
        CONTEXT_MAP.put(key, context);
    }

    public static boolean containsKey(String key) {
        if (!StringUtils.hasLength(key)) {
            return false;
        }
        return CONTEXT_MAP.containsKey(key);
    }
}

MapSecurityContextRepository.java

package org.example.server.security;

import jakarta.servlet.http.HttpServletRequest;
import jakarta.servlet.http.HttpServletResponse;
import org.springframework.security.core.context.SecurityContext;
import org.springframework.security.web.context.HttpRequestResponseHolder;
import org.springframework.security.web.context.SecurityContextRepository;
import org.springframework.util.StringUtils;

/**
 * 安全上下文存储
 *
 * @author qiongying.huai
 * @version 1.0
 * @date 17:57 2025/7/16
 */
public class MapSecurityContextRepository implements SecurityContextRepository {

    @Override
    public SecurityContext loadContext(HttpRequestResponseHolder requestResponseHolder) {
        String token = requestResponseHolder.getRequest().getHeader("token");
        return MapSecurityContextHolder.getContext(token);
    }

    @Override
    public void saveContext(SecurityContext context, HttpServletRequest request, HttpServletResponse response) {
        String token = request.getHeader("token");
        if (StringUtils.hasLength(token)) {
            MapSecurityContextHolder.addContext(token, context);
        }
    }

    @Override
    public boolean containsContext(HttpServletRequest request) {
        return MapSecurityContextHolder.containsKey(request.getHeader("token"));
    }
}

OkAuthenticationSuccessHandler.java

package org.example.server.security;

import jakarta.servlet.ServletException;
import jakarta.servlet.http.HttpServletRequest;
import jakarta.servlet.http.HttpServletResponse;
import org.springframework.http.HttpStatus;
import org.springframework.security.core.Authentication;
import org.springframework.security.oauth2.core.endpoint.OAuth2ParameterNames;
import org.springframework.security.oauth2.server.authorization.authentication.OAuth2AuthorizationCodeRequestAuthenticationToken;
import org.springframework.security.oauth2.server.authorization.web.OAuth2AuthorizationEndpointFilter;
import org.springframework.security.web.DefaultRedirectStrategy;
import org.springframework.security.web.RedirectStrategy;
import org.springframework.security.web.authentication.AuthenticationSuccessHandler;
import org.springframework.util.StringUtils;
import org.springframework.web.util.UriComponentsBuilder;
import org.springframework.web.util.UriUtils;

import java.io.IOException;
import java.nio.charset.StandardCharsets;

/**
 * 让获取授权码接口返回200而不是302重定向
 * 因为跳转到不到域的地址重定向会有跨域问题,返回地址让前端跳转
 *
 * @author qiongying.huai
 * @version 1.0
 * @date 10:11 2025/7/17
 * @see OAuth2AuthorizationEndpointFilter#sendAuthorizationResponse
 */
public class OkAuthenticationSuccessHandler implements AuthenticationSuccessHandler {

    private final RedirectStrategy redirectStrategy;

    public OkAuthenticationSuccessHandler() {
        DefaultRedirectStrategy strategy = new DefaultRedirectStrategy();
        strategy.setStatusCode(HttpStatus.OK);
        redirectStrategy = strategy;
    }

    @Override
    public void onAuthenticationSuccess(HttpServletRequest request, HttpServletResponse response, Authentication authentication) throws IOException, ServletException {
        OAuth2AuthorizationCodeRequestAuthenticationToken authorizationCodeRequestAuthentication = (OAuth2AuthorizationCodeRequestAuthenticationToken) authentication;
        UriComponentsBuilder uriBuilder = UriComponentsBuilder
                .fromUriString(authorizationCodeRequestAuthentication.getRedirectUri())
                .queryParam(OAuth2ParameterNames.CODE,
                        authorizationCodeRequestAuthentication.getAuthorizationCode().getTokenValue());
        if (StringUtils.hasText(authorizationCodeRequestAuthentication.getState())) {
            uriBuilder.queryParam(OAuth2ParameterNames.STATE,
                    UriUtils.encode(authorizationCodeRequestAuthentication.getState(), StandardCharsets.UTF_8));
        }
        // build(true) -> Components are explicitly encoded
        String redirectUri = uriBuilder.build(true).toUriString();
        this.redirectStrategy.sendRedirect(request, response, redirectUri);
    }
}

TokenFilter.java

package org.example.server.security;

import jakarta.servlet.FilterChain;
import jakarta.servlet.ServletException;
import jakarta.servlet.http.HttpServletRequest;
import jakarta.servlet.http.HttpServletResponse;
import org.springframework.security.core.context.DeferredSecurityContext;
import org.springframework.security.core.context.SecurityContext;
import org.springframework.security.core.context.SecurityContextHolder;
import org.springframework.security.web.context.SecurityContextRepository;
import org.springframework.util.StringUtils;
import org.springframework.web.filter.OncePerRequestFilter;

import java.io.IOException;

/**
 * 从token的本地缓存中拿安全上下文
 *
 * @author qiongying.huai
 * @version 1.0
 * @date 11:05 2025/7/16
 */
public class TokenFilter extends OncePerRequestFilter {

    private final SecurityContextRepository securityContextRepository;

    public TokenFilter(SecurityContextRepository securityContextRepository) {
        this.securityContextRepository = securityContextRepository;
    }

    @Override
    protected void doFilterInternal(HttpServletRequest request, HttpServletResponse response, FilterChain filterChain) throws ServletException, IOException {
        String token = request.getHeader("token");
        if (StringUtils.hasLength(token)) {
            DeferredSecurityContext deferredSecurityContext = securityContextRepository.loadDeferredContext(request);
            if (deferredSecurityContext != null) {
                SecurityContext securityContext = deferredSecurityContext.get();
                if (securityContext != null) {
                    SecurityContextHolder.setContext(securityContext);
                }
            }
        }
        filterChain.doFilter(request, response);
    }
}

ServerLoginController.java

package org.example.server.controller;

import org.example.server.security.MapSecurityContextHolder;
import org.springframework.security.authentication.AuthenticationManager;
import org.springframework.security.authentication.UsernamePasswordAuthenticationToken;
import org.springframework.security.core.Authentication;
import org.springframework.security.core.context.SecurityContext;
import org.springframework.security.core.context.SecurityContextHolder;
import org.springframework.web.bind.annotation.PostMapping;
import org.springframework.web.bind.annotation.RequestBody;
import org.springframework.web.bind.annotation.RestController;

import java.util.Map;
import java.util.UUID;

/**
 * 登录接口
 *
 * @author qiongying.huai
 * @version 1.0
 * @date 13:46 2025/7/14
 */
@RestController
public class ServerLoginController {

    private final AuthenticationManager authenticationManager;

    public ServerLoginController(AuthenticationManager authenticationManager) {
        this.authenticationManager = authenticationManager;
    }

    @PostMapping("/login")
    public String login(@RequestBody Map<String, String> params) {
        String username = params.get("username");
        String password = params.get("password");
        UsernamePasswordAuthenticationToken token = new UsernamePasswordAuthenticationToken(username, password);
        Authentication authenticate = authenticationManager.authenticate(token);
        if (!authenticate.isAuthenticated()) {
            throw new RuntimeException("登录失败");
        }
        String id = UUID.randomUUID().toString().replaceFirst("-", "");
        SecurityContext context = SecurityContextHolder.getContext();
        context.setAuthentication(authenticate);
        MapSecurityContextHolder.addContext(id, context);
        return id;
    }
}