前情提要

上一集,我们给 Vue 前端留了个"授权码登录"的按钮,像个待填的坑。今天,我们亲手把这个坑填平了!

1. 改造Login.vue ,让按钮真正干活

axios.get('http://localhost:8124/api/oauth/authorize')
            .then(response => {
              // ✅ axios 已经把 JSON 转好了,直接取 response.data
              window.location.href = response.data.authorizeUrl;
            })
            .catch(error => {
              console.error('获取授权地址失败:', error);
              // 如果错误,可以跳转到登录页或提示
              // window.location.href = '/login';
            });

前端 → 登录微服务 → 获取授权地址

前端拿到地址后,浏览器整页跳转到授权服务器

2.登录微服务 —— 生成授权地址

登录微服务需要提供一个接口,帮前端生成完整的授权地址:

@GetMapping("/oauth/authorize")
public Map<String, String> getAuthorizeUrl() {
    String authorizeUrl = UriComponentsBuilder.fromHttpUrl(authorizeUri)
            .queryParam("client_id", clientId)
            .queryParam("redirect_uri", redirectUri)
            .queryParam("response_type", "code")
            .queryParam("scope", "read write")
            .build()
            .toUriString();

    log.info("🔐 生成授权地址: {}", authorizeUrl);

    Map<String,String> resultMap = new HashMap<>();
    resultMap.put("authorizeUrl",authorizeUrl);
    return resultMap;
}

关键点:redirect_uri 指向的是 Vue 前端的 /callback 页面,不是登录微服务!

3.创建 CallbackPage.vue —— 接收授权码

授权服务器登录成功后,会重定向到 http://localhost:8080/callback?code=xxxxx,这个页面就是负责接收 code 并换 Token 的

<template>
  <div>正在处理授权...</div>
</template>

<script>
export default {
  async mounted() {
    const urlParams = new URLSearchParams(window.location.search);
    const code = urlParams.get('code');
    if (code) {
      try {
        // 调用后端接口,用 code 换 token
        const response = await fetch('http://localhost:8124/api/callback?code=' + code);
        const tokenData = await response.json();

        // 存储 token(这里假设后端返回的是 JSON)
        localStorage.setItem('token', tokenData.accessToken);

        // 跳转到首页
        this.$router.push('/home');
      } catch (error) {
        console.error('授权失败', error);
        this.$router.push('/login');
      }
    } else {
      this.$router.push('/login');
    }
  }
};
</script>

修改一下路由 main.js

import CallbackPage from './components/CallbackPage.vue'
const routes = [
    { path: '/', redirect: '/login' },
    { path: '/login', component: Login },
    { path: '/callback', component: CallbackPage },
    { path: '/home', component: Home }
]

4.登录微服务的 Callback 接口 —— 换 Token

@GetMapping("/callback")
public LoginResponse callback(@RequestParam("code") String code) {
    log.info("📨 收到授权码回调: {}", code);

    try {
        // 用授权码换取 Token
        LoginResponse tokenResponse = exchangeCodeForToken(code);
        log.info("✅ Token 换取成功");
        return tokenResponse;
    } catch (Exception e) {
        log.error("❌ 换取 Token 失败", e);
        throw new RuntimeException("授权码换取 Token 失败: " + e.getMessage());
    }
}

/**
 * 3. 用授权码换 Token(核心逻辑)
 */
private LoginResponse exchangeCodeForToken(String code) {
    HttpHeaders headers = new HttpHeaders();
    headers.setContentType(MediaType.APPLICATION_FORM_URLENCODED);
    headers.setBasicAuth(clientId, clientSecret);  // 后端持有 client-secret

    MultiValueMap<String, String> body = new LinkedMultiValueMap<>();
    body.add("grant_type", "authorization_code");
    body.add("code", code);
    body.add("redirect_uri", redirectUri);  // 必须和获取授权码时一致

    HttpEntity<MultiValueMap<String, String>> request = new HttpEntity<>(body, headers);

    ResponseEntity<Map> response = restTemplate.postForEntity(
            tokenUri,
            request,
            Map.class
    );

    if (response.getStatusCode() == HttpStatus.OK && response.getBody() != null) {
        Map<String, Object> tokenInfo = response.getBody();
        return new LoginResponse(
                (String) tokenInfo.get("access_token"),
                (String) tokenInfo.get("refresh_token"),
                (String) tokenInfo.get("token_type"),
                ((Number) tokenInfo.get("expires_in")).longValue(),
                (String) tokenInfo.get("scope")
        );
    } else {
        throw new RuntimeException("获取令牌失败,状态码: " + response.getStatusCode());
    }
}

client-secret 只在服务端出现,前端完全不知道!

redirect_uri 必须和获取授权码时完全一致,否则授权服务器会拒绝!

5.配置大汇总

spring:
    application:
        name: login
server:
    port: 8124

security:
    oauth2:
        resource:
            jwt:
                key-uri: http://localhost:8129/oauth/token_key

auth:
    server:
        base:
            url: http://localhost:8129
        # 新增授权码模式相关配置
        authorize:
            uri: ${auth.server.base.url}/oauth/authorize
        token:
            uri: ${auth.server.base.url}/oauth/token
client:
    client-id: client-app
    client-secret: secret123
    # 新增回调地址(登录微服务的回调接口)
    redirect-uri: http://localhost:8080/callback

授权服务器配置

// 回调地址
.redirectUris("http://localhost:8080/callback")

6.授权服务器 Security 配置

protected void configure(HttpSecurity http) throws Exception {
        http.csrf().disable();
        http.authorizeRequests()
                .antMatchers("/login.html").permitAll()
                .antMatchers("/.well-known/**").permitAll()
//                .antMatchers("/oauth/**").permitAll()
                .anyRequest().authenticated()
                .and()
                .formLogin()
                .loginPage("/login.html")
                .loginProcessingUrl("/login")
                .successHandler(new SavedRequestAwareAuthenticationSuccessHandler())
                .failureHandler(new SimpleUrlAuthenticationFailureHandler() {
                    @Override
                    public void onAuthenticationFailure(javax.servlet.http.HttpServletRequest request,
                                                        javax.servlet.http.HttpServletResponse response,
                                                        AuthenticationException exception) throws java.io.IOException {
                        response.sendError(401, "Authentication Failed");
                    }
                });
    }

security有自带的 登录页面 ,如果不想自定义 可以把.loginPage(“/login.html”)
.loginProcessingUrl(“/login”)注释掉

7.授权服务器创建自定义登录页面(如果选择自定义)

resources —> static ----> login.html

<!DOCTYPE html>
<html lang="zh-CN">
<head>
    <meta charset="UTF-8">
    <meta name="viewport" content="width=device-width, initial-scale=1.0">
    <title>统一授权登录</title>
    <style>
        * {
            margin: 0;
            padding: 0;
            box-sizing: border-box;
        }

        body {
            font-family: 'Microsoft YaHei', -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif;
            background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
            height: 100vh;
            display: flex;
            justify-content: center;
            align-items: center;
            margin: 0;
        }

        .login-container {
            background: white;
            padding: 48px 40px 40px;
            border-radius: 16px;
            box-shadow: 0 20px 60px rgba(0, 0, 0, 0.3);
            width: 420px;
            max-width: 95%;
            animation: fadeIn 0.5s ease-out;
        }

        @keyframes fadeIn {
            from {
                opacity: 0;
                transform: translateY(-20px);
            }
            to {
                opacity: 1;
                transform: translateY(0);
            }
        }

        .login-header {
            text-align: center;
            margin-bottom: 32px;
        }

        .login-header .logo {
            font-size: 48px;
            margin-bottom: 8px;
        }

        .login-header h2 {
            color: #333;
            font-size: 24px;
            font-weight: 700;
            margin-bottom: 4px;
        }

        .login-header .subtitle {
            color: #999;
            font-size: 13px;
        }

        .login-header .client-info {
            background: #f0f4ff;
            color: #667eea;
            padding: 8px 16px;
            border-radius: 20px;
            font-size: 13px;
            display: inline-block;
            margin-top: 10px;
        }

        .form-group {
            margin-bottom: 20px;
        }

        .form-group label {
            display: block;
            margin-bottom: 6px;
            color: #555;
            font-weight: 500;
            font-size: 14px;
        }

        .form-group input {
            width: 100%;
            padding: 12px 16px;
            border: 2px solid #e8ecf1;
            border-radius: 10px;
            font-size: 15px;
            transition: border-color 0.3s, box-shadow 0.3s;
            background: #fafbfc;
            box-sizing: border-box;
        }

        .form-group input:focus {
            outline: none;
            border-color: #667eea;
            box-shadow: 0 0 0 4px rgba(102, 126, 234, 0.15);
            background: white;
        }

        .form-group input::placeholder {
            color: #b0b8c4;
        }

        .btn-login {
            width: 100%;
            padding: 14px;
            background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
            color: white;
            border: none;
            border-radius: 10px;
            font-size: 16px;
            font-weight: 600;
            cursor: pointer;
            transition: transform 0.2s, box-shadow 0.2s;
            margin-top: 8px;
        }

        .btn-login:hover {
            transform: translateY(-2px);
            box-shadow: 0 8px 25px rgba(102, 126, 234, 0.4);
        }

        .btn-login:active {
            transform: translateY(0);
        }

        .error-msg {
            background: #fef2f2;
            color: #dc2626;
            padding: 12px 16px;
            border-radius: 10px;
            margin-bottom: 20px;
            font-size: 14px;
            display: none;
            border-left: 4px solid #dc2626;
        }

        .error-msg.show {
            display: block;
        }

        .login-footer {
            text-align: center;
            margin-top: 24px;
            color: #b0b8c4;
            font-size: 12px;
        }

        .login-footer a {
            color: #667eea;
            text-decoration: none;
        }

        .security-badge {
            display: flex;
            justify-content: center;
            gap: 16px;
            margin-top: 16px;
            font-size: 12px;
            color: #b0b8c4;
        }

        .security-badge span {
            display: flex;
            align-items: center;
            gap: 4px;
        }
    </style>
</head>
<body>
<div class="login-container">
    <div class="login-header">
        <div class="logo">🔐</div>
        <h2>安全授权登录</h2>
        <p class="subtitle">请输入您的账号信息以继续</p>
        <div class="client-info" id="clientDisplay">
            📱 正在为 <strong>未知应用</strong> 授权
        </div>
    </div>

    <div class="error-msg" id="errorMsg">
        ❌ 用户名或密码错误,请重新输入
    </div>

    <form id="loginForm" method="post" action="/login">
        <div class="form-group">
            <label for="username">👤 用户名</label>
            <input
                    type="text"
                    id="username"
                    name="username"
                    placeholder="请输入用户名"
                    value="user"
                    required
                    autofocus
            >
        </div>

        <div class="form-group">
            <label for="password">🔒 密码</label>
            <input
                    type="password"
                    id="password"
                    name="password"
                    placeholder="请输入密码"
                    value="password"
                    required
            >
        </div>



        <button type="submit" class="btn-login">🚀 立即授权登录</button>
    </form>

    <div class="security-badge">
        <span>🔒 SSL 加密</span>
        <span>🛡️ 安全认证</span>
        <span>OAuth 2.0</span>
    </div>

    <div class="login-footer">
        授权服务器 v2.0 · 您的密码不会被存储
    </div>
</div>

<script>

</script>
</body>
</html>

测试!见证奇迹的时刻

1.点击授权码登录 按钮
在这里插入图片描述
2.重定向到授权服务器的 login.html
在这里插入图片描述
3. 输入用户名密码 user 1,点击立即授权登录,登录成功后,自动跳转到 http://localhost:8080/callback?code=xxxxx,然后自动换 Token,最后进入 Home 页面:
在这里插入图片描述

完整流程

在这里插入图片描述

分享一个此案例中遇到的一个坑

如果你在点击授权页面(login.html)的登录 之后 出现下面这个页面。
在这里插入图片描述
不用慌 ,在 MyWebSecurityConfigAdapter 中去掉 .well-known的认证

 http.csrf().disable();
        http.authorizeRequests()
                .antMatchers("/login.html").permitAll()
                .antMatchers("/.well-known/**").permitAll()		//这里这里

今天遇到这个问题的时候 ,我还思考了半天,一开始我寻思是个小问题,就让 灵码 给我解决,没想到 这货一直跟我说不可能 ,还说google的插件问题,几个小时也没解决了,后来又去问了问 deepseek,它回答的也不咋地。没办法 自己研究吧
原因是

  • 第一次请求 /oauth/authorize?client_id=xxx 被 Spring Security 保存到 Session
  • 紧接着,Chrome DevTools
    偷偷发了一个请求:/.well-known/appspecific/com.chrome.devtools.json
  • 这个请求也被 Spring Security 拦截,覆盖了 Session 中保存的原始请求!
  • 登录成功后,SavedRequestAwareAuthenticationSuccessHandler 从 Session 取到的是
    .well-known 地址
  • 跳转到 .well-known → 404
Logo

openEuler 是由开放原子开源基金会孵化的全场景开源操作系统项目,面向数字基础设施四大核心场景(服务器、云计算、边缘计算、嵌入式),全面支持 ARM、x86、RISC-V、loongArch、PowerPC、SW-64 等多样性计算架构

更多推荐