整体流程
前端
技术栈
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;
}
}