第一章:OAuth2 基础概念与核心角色
1.1 什么是OAuth2?——授权协议的本质
| 概念名称 | 说明 | 注意事项 |
|---|---|---|
| OAuth2 | 开放授权标准,允许第三方应用在用户授权下访问其在资源服务器上的受保护资源,而无需获取用户密码。 | OAuth2 是授权协议,不是认证协议,重点在于”委托访问”。 |
| 授权委托 | 用户将对某资源的部分或全部操作权限,委托给第三方客户端应用。 | 必须明确权限范围(scope),避免过度授权。 |
| 不共享密码 | 第三方应用无法获知用户在资源服务器上的登录凭证。 | 提高安全性,防止密码泄露。 |
| 标准化流程 | 定义了统一的授权流程和令牌机制,便于跨系统集成。 | 支持多种授权模式,适应不同客户端类型(如Web、移动、桌面应用)。 |
1.2 OAuth2 的四大核心角色
| 角色名称 | 说明 | 注意事项 |
|---|---|---|
| 资源所有者 (Resource Owner) | 拥有受保护资源的用户或账户,有权决定是否授权第三方访问其资源。 | 通常是最终用户(End User),授权需其明确同意。 |
| 客户端 (Client) | 请求访问资源的第三方应用,如Web应用、移动App、单页应用等。 | 客户端需在授权服务器注册,获得 client_id 和 client_secret。 |
| 资源服务器 (Resource Server) | 存储和提供受保护资源的服务,如用户头像、好友列表、邮件等。 | 仅接受有效访问令牌(Access Token)的请求。 |
| 授权服务器 (Authorization Server) | 负责验证用户身份并颁发访问令牌的服务,是OAuth2的核心组件。 | 可与资源服务器合并部署,也可独立存在。 |
1.3 OAuth2 与认证(Authentication)和授权(Authorization)的区别
| 概念 | 说明 | 注意事项 |
|---|---|---|
| 认证 (Authentication) | 验证”你是谁”,即确认用户的身份,如用户名/密码登录。 | OAuth2 本身不处理认证,但常与认证机制结合使用(如OpenID Connect)。 |
| 授权 (Authorization) | 验证”你能做什么”,即确认用户是否有权限执行某操作。 | OAuth2 的核心目标是实现授权,控制第三方访问资源的权限。 |
| 区别示例 | 登录微信是”认证”;允许某个小程序读取你的头像和昵称是”授权”。 | 不应将OAuth2误用为用户登录系统(除非结合OpenID Connect)。 |
1.4 OAuth2 的典型应用场景
| 应用场景 | 说明 | 注意事项 |
|---|---|---|
| 第三方登录 | 用户使用微信、Google等账号登录第三方网站。 | 实际是结合OpenID Connect实现的认证,OAuth2提供授权基础。 |
| 社交分享 | 应用请求权限,将内容发布到用户的微博、朋友圈。 | 需明确请求的权限范围(如”发布动态”)。 |
| API 访问授权 | 企业内部微服务之间或开放平台API的访问控制。 | 可使用客户端模式或授权码模式。 |
| 移动App访问云服务 | 如手机App访问用户的网盘文件。 | 推荐使用授权码模式 + PKCE,确保移动环境安全。 |
第二章:OAuth2 的四种授权模式
2.1 授权码模式(Authorization Code Flow)
| 方法/参数 | 用途 | 注意事项 |
|---|---|---|
authorization_endpoint | 请求授权码 | 必须包含 state 防止CSRF;redirect_uri 必须预注册 |
code | 授权码,用于换取访问令牌 | 授权码一次性使用,有效期短(通常数分钟) |
token_endpoint | 用授权码换取访问令牌 | 请求必须包含 client_id 和 client_secret(机密客户端) |
grant_type | 指定授权类型为授权码模式 | 固定值 authorization_code |
code (POST body) | 提供授权码 | 必须与上一步获取的code一致 |
redirect_uri (POST body) | 重定向URI,必须与授权请求一致 | 必须严格匹配 |
client_id | 客户端标识 | 公开参数 |
client_secret | 客户端密钥(机密客户端) | 不能在公共客户端(如SPA、移动App)中暴露 |
access_token (响应) | 访问令牌,用于访问资源 | Bearer Token,需在Authorization头中携带 |
token_type (响应) | 令牌类型 | 表示使用Bearer方案 |
expires_in (响应) | 令牌有效期(秒) | 建议缓存并及时刷新 |
refresh_token (响应) | 用于刷新访问令牌 | 可选,安全存储 |
语法与代码示例:
# 步骤1:请求授权码
GET /authorize?response_type=code&client_id=CLIENT_ID&redirect_uri=REDIRECT_URI&scope=SCOPE&state=STATE
示例:
/authorize?response_type=code&client_id=abc123&redirect_uri=https%3A%2F%2Fclient.com%2Fcb&scope=read&state=xyz789
返回授权码:
?code=AUTHORIZATION_CODE
示例:
code=authz123xyz
# 步骤2:用授权码换取访问令牌
POST /token
# 请求体参数
grant_type=authorization_code
code=AUTHORIZATION_CODE
redirect_uri=REDIRECT_URI
client_id=CLIENT_ID
client_secret=CLIENT_SECRET
示例:
grant_type=authorization_code&code=authz123xyz&redirect_uri=https%3A%2F%2Fclient.com%2Fcb&client_id=abc123&client_secret=secret456
响应:
{
"access_token": "eyJhbGciOiJIUzI1NiIs...",
"token_type": "Bearer",
"expires_in": 3600,
"refresh_token": "refresh999"
}
2.2 隐式模式(Implicit Flow)
| 方法/参数 | 用途 | 注意事项 |
|---|---|---|
authorization_endpoint | 直接获取访问令牌 | 适用于无法保密client_secret的客户端(如SPA) |
response_type | 指定为隐式模式 | response_type=token,令牌通过重定向URI的片段(fragment)返回 |
access_token (fragment) | 访问令牌直接返回 | 令牌暴露在浏览器历史中,安全性较低 |
token_type (fragment) | 令牌类型 | token_type=Bearer |
expires_in (fragment) | 有效期 | expires_in=3600 |
state | 防止CSRF攻击 | 必须验证返回的state是否一致 |
scope | 请求的权限范围 | 多个scope用空格分隔 |
语法与代码示例:
GET /authorize?response_type=token&client_id=CLIENT_ID&redirect_uri=REDIRECT_URI&scope=SCOPE&state=STATE
示例:
/authorize?response_type=token&client_id=abc123&redirect_uri=https%3A%2F%2Fclient.com%2Fcb&scope=read&state=xyz789
返回(通过URL fragment):
#access_token=ACCESS_TOKEN&token_type=Bearer&expires_in=3600
示例:
#access_token=eyJhbGciOiJIUzI1NiIs...&token_type=Bearer&expires_in=3600
⚠️ 注意:隐式模式因安全问题(令牌暴露在URL中)已被现代应用逐步弃用,推荐使用授权码模式 + PKCE替代。
2.3 密码模式(Resource Owner Password Credentials Flow)
| 方法/参数 | 用途 | 注意事项 |
|---|---|---|
token_endpoint | 使用用户名密码直接获取令牌 | 仅适用于高度信任的客户端(如官方App) |
grant_type | 指定为密码模式 | 固定值 password |
username | 用户名 | 客户端需收集用户凭证 |
password | 用户密码 | 客户端会接触到用户密码,风险高 |
scope | 请求的权限范围 | scope=read |
client_id | 客户端ID | 建议提供 |
client_secret | 客户端密钥 | 增强安全性 |
access_token (响应) | 返回的访问令牌 | access_token=eyJ... |
refresh_token (响应) | 刷新令牌 | 可选 |
语法与代码示例:
POST /token
# 请求体参数
grant_type=password
username=USER_NAME
password=PASSWORD
scope=SCOPE
client_id=CLIENT_ID
client_secret=CLIENT_SECRET
示例:
grant_type=password&username=alice&password=secret123&scope=read&client_id=abc123&client_secret=secret456
响应:
{
"access_token": "eyJ...",
"refresh_token": "ref999"
}
⚠️ 注意:此模式要求用户将密码直接交给客户端,违背OAuth2”不共享密码”原则,不推荐用于第三方应用,仅限系统间信任场景。
2.4 客户端模式(Client Credentials Flow)
| 方法/参数 | 用途 | 注意事项 |
|---|---|---|
token_endpoint | 客户端凭据获取令牌 | 用于客户端访问自身资源或公共服务 |
grant_type | 指定为客户端模式 | 固定值 client_credentials |
client_id | 客户端ID | 必须提供 |
client_secret | 客户端密钥 | 必须安全存储 |
scope | 请求的权限范围 | 可选,取决于服务器配置 |
access_token (响应) | 返回的访问令牌 | 令牌代表客户端,非用户 |
token_type (响应) | 令牌类型 | token_type=Bearer |
expires_in (响应) | 有效期 | expires_in=7200 |
语法与代码示例:
POST /token
# 请求体参数
grant_type=client_credentials
client_id=CLIENT_ID
client_secret=CLIENT_SECRET
scope=SCOPE
示例:
grant_type=client_credentials&client_id=service123&client_secret=secret789&scope=api.read
响应:
{
"access_token": "eyJ...",
"token_type": "Bearer",
"expires_in": 7200
}
⚠️ 注意:此模式无用户参与,适用于服务间通信(如微服务调用),令牌权限由客户端权限决定,而非用户。
第三章:OAuth2 的核心流程与令牌管理
3.1 授权码流程详解(含PKCE扩展)
| 方法/参数 | 用途 | 注意事项 |
|---|---|---|
authorization_endpoint | 请求授权码(含PKCE) | PKCE用于防止授权码拦截攻击,尤其适用于公共客户端 |
response_type | 指定获取授权码 | response_type=code,固定值 |
client_id | 客户端唯一标识 | 必须在授权服务器注册 |
redirect_uri | 授权后重定向地址 | 必须与注册的回调地址匹配 |
scope | 请求的权限范围 | 多个权限用空格分隔 |
state | 防止CSRF和保持请求状态 | 必须随机生成,回调时验证一致性 |
code_challenge | 授权码挑战值(PKCE) | 由code_verifier生成 |
code_challenge_method | 挑战生成方法 | S256(推荐)或 plain |
code (URL参数) | 授权服务器返回的授权码 | 一次性使用,有效期短(通常1-5分钟) |
token_endpoint | 用授权码换取访问令牌 | 必须使用POST请求 |
grant_type (token) | 指定为授权码模式 | grant_type=authorization_code,固定值 |
code (POST body) | 提交授权码 | 必须与上一步一致 |
redirect_uri (POST body) | 重定向URI | 必须与授权请求中的一致 |
client_id (token) | 客户端ID | 公共客户端可不提供secret |
client_secret (token) | 客户端密钥(机密客户端) | 仅机密客户端需要,公共客户端使用PKCE替代 |
code_verifier | 挑战校验值(PKCE) | 原始随机字符串,用于生成code_challenge |
access_token (响应) | 成功响应中的访问令牌 | Bearer Token |
refresh_token (响应) | 刷新令牌(可选) | 用于获取新access_token |
语法与代码示例:
# 步骤1:请求授权码(含PKCE参数)
GET /authorize?response_type=code
&client_id=CLIENT_ID
&redirect_uri=REDIRECT_URI
&scope=SCOPE
&state=STATE
&code_challenge=CODE_CHALLENGE
&code_challenge_method=METHOD
示例:
/authorize?response_type=code&client_id=abc123&redirect_uri=https%3A%2F%2Fclient.com%2Fcb&scope=read&state=xyz789&code_challenge=bX5XVcG...&code_challenge_method=S256
返回授权码:
?code=AUTHORIZATION_CODE
示例:
code=authz123xyz
# 步骤2:用授权码换取访问令牌
POST /token
# 请求体参数
grant_type=authorization_code
code=AUTHORIZATION_CODE
redirect_uri=REDIRECT_URI
client_id=CLIENT_ID
client_secret=CLIENT_SECRET
code_verifier=VERIFIER_STRING
示例:
grant_type=authorization_code&code=authz123xyz&redirect_uri=https%3A%2F%2Fclient.com%2Fcb&client_id=abc123&client_secret=secret456&code_verifier=67890abc...
响应:
{
"access_token": "eyJhbGciOiJIUzI1NiIs...",
"refresh_token": "refresh999"
}
⚠️ 注意:PKCE(RFC 7636)是现代OAuth2实现的重要安全增强,推荐所有公共客户端(如SPA、移动App)强制启用。
3.2 访问令牌(Access Token)的获取与使用
| 方法/参数 | 用途 | 注意事项 |
|---|---|---|
Authorization Header | 在HTTP请求头中携带令牌 | 标准方式,资源服务器通过此头验证身份 |
access_token (响应) | 从授权服务器获取的令牌 | 通常为JWT格式或 opaque 字符串 |
token_type (响应) | 令牌类型 | 表示使用Bearer认证方案 |
expires_in (响应) | 令牌有效期(秒) | 应在过期前刷新 |
scope (响应) | 令牌拥有的权限范围 | 资源服务器据此判断是否允许操作 |
| Resource Server Request | 使用令牌访问受保护资源 | 所有API请求必须携带有效令牌 |
401 Unauthorized | 令牌缺失或无效 | 客户端应跳转至登录或尝试刷新令牌 |
403 Forbidden | 令牌有效但无足够权限 | 检查scope是否包含所需权限 |
语法与代码示例:
# 在HTTP请求头中携带令牌
Authorization: Bearer ACCESS_TOKEN
示例:
Authorization: Bearer eyJhbGciOiJIUzI1NiIs...
# 使用令牌访问受保护资源
GET /api/user HTTP/1.1
Host: api.example.com
Authorization: Bearer ACCESS_TOKEN
# 令牌缺失或无效时的响应
HTTP/1.1 401 Unauthorized
WWW-Authenticate: Bearer
# 令牌有效但无足够权限时的响应
HTTP/1.1 403 Forbidden
⚠️ 注意:访问令牌应在HTTPS下传输;避免日志记录;建议使用短期令牌(如1小时)以降低泄露风险。
3.3 刷新令牌(Refresh Token)机制
| 方法/参数 | 用途 | 注意事项 |
|---|---|---|
token_endpoint | 使用刷新令牌获取新访问令牌 | 独立于授权码流程 |
grant_type | 指定为刷新令牌模式 | 固定值 refresh_token |
refresh_token | 提供旧的刷新令牌 | 由授权服务器首次发放 |
client_id | 客户端ID | 必须提供 |
client_secret | 客户端密钥(如适用) | 机密客户端需提供 |
scope | 可请求缩小范围 | 不能扩大原始授权范围 |
access_token (响应) | 新的访问令牌 | 原access_token立即失效 |
refresh_token (响应) | 新的刷新令牌(可选) | 推荐滚动更新(每次刷新都换新) |
expires_in (响应) | 新令牌有效期 | expires_in=3600 |
语法与代码示例:
POST /token
# 请求体参数
grant_type=refresh_token
refresh_token=REFRESH_TOKEN
client_id=CLIENT_ID
client_secret=CLIENT_SECRET
scope=NEW_SCOPE
示例:
grant_type=refresh_token&refresh_token=ref999xyz&client_id=abc123&client_secret=secret456&scope=read
响应:
{
"access_token": "new_jwttoken...",
"refresh_token": "new_ref999",
"expires_in": 3600
}
⚠️ 注意:刷新令牌应长期有效但可撤销;必须安全存储(服务端优先);支持”一次一刷新”或”滚动刷新”策略;防止刷新令牌泄露。
3.4 令牌的生命周期与安全策略
| 概念/方法 | 说明 | 注意事项 |
|---|---|---|
| 令牌签发 | 授权服务器在用户授权后颁发 access_token 和可选 refresh_token | 必须验证客户端身份和用户授权 |
| 令牌使用 | 客户端在调用资源服务器API时携带 access_token | 使用 HTTPS + Authorization: Bearer 头 |
| 令牌过期 | access_token 在 expires_in 秒后失效 | 客户端应缓存过期时间,提前刷新 |
| 令牌刷新 | 使用 refresh_token 获取新的 access_token | refresh_token 可能被服务器撤销或轮换 |
| 令牌撤销 | 用户或管理员主动使令牌失效(如登出、取消授权) | 授权服务器维护撤销列表或使用短期令牌+黑名单 |
| 令牌吊销端点 | 主动请求吊销令牌(RFC 7009) | POST /revoke |
| 安全存储 | access_token 存于内存或安全上下文;refresh_token 存于服务端安全存储 | 避免 localStorage(XSS风险) |
| HTTPS 强制 | 所有涉及令牌的通信必须使用 HTTPS | 防止中间人窃听 |
| 最小权限原则 | 按需申请最小 scope | 降低令牌泄露后的危害 |
| 短期令牌 | access_token 有效期建议 ≤1小时 | 减少暴露窗口 |
第四章:OAuth2 扩展机制与安全增强
4.1 PKCE(Proof Key for Code Exchange)
| 方法/参数 | 用途 | 注意事项 |
|---|---|---|
code_verifier | 随机生成的高熵字符串 | 客户端本地生成,永不发送给授权服务器 |
code_challenge | code_verifier 的哈希值 | 发送至授权服务器 |
code_challenge_method | 哈希方法 | S256 强制要求,plain 已弃用 |
| authorization request | 授权请求中包含挑战 | 必须在初始授权请求中发送 |
| token request | 令牌请求中提交校验值 | code_verifier=67890abc... |
| 验证逻辑 | 服务器验证校验值 | SHA256(code_verifier) == code_challenge,匹配则发放令牌,否则拒绝 |
语法与代码示例:
# 生成 code_verifier
code_verifier = BASE64URL-ENCODE(RANDOM_BYTES(32..96))
示例:
code_verifier=67890abc...xyz123
# 生成 code_challenge
code_challenge = BASE64URL-ENCODE(SHA256(code_verifier))
示例:
code_challenge=bX5XVcG...
# 授权请求中包含挑战值
...&code_challenge=CHALLENGE&code_challenge_method=S256
# 令牌请求中提交校验值
...&code_verifier=VERIFIER
⚠️ 注意:PKCE解决了公共客户端无法安全存储
client_secret的问题,已成为现代OAuth2(尤其是移动端和SPA)的必备安全实践。
4.2 Scope(作用域)的定义与控制
| 概念/参数 | 说明 | 注意事项 |
|---|---|---|
scope | 表示客户端请求的权限范围 | 如 read、write、email、profile |
| 请求Scope | 客户端在授权请求中指定所需权限 | scope=read write |
| 授权Scope | 用户最终同意的权限集合 | 可小于请求的scope |
| 颁发Scope | 授权服务器实际颁发的scope(≤授权scope) | 响应中返回实际授予的scope |
| 默认Scope | 未指定时自动应用的权限 | 需谨慎配置,避免过度授权 |
| 细粒度控制 | 支持命名空间式scope,如 api.user.read、api.order.write | 便于权限精细化管理 |
| Scope验证 | 资源服务器检查 access_token 中的scope 是否包含操作所需权限 | 如删除操作需 write 权限 |
| 动态Scope | 支持运行时注册和管理scope | 适合复杂系统 |
⚠️ 注意:应遵循最小权限原则;向用户清晰展示每个scope的含义;避免使用模糊的scope(如
all)。
4.3 OAuth2 与 OpenID Connect 简介
| 概念/参数 | 说明 | 注意事项 |
|---|---|---|
| OpenID Connect (OIDC) | 建立在OAuth2之上的身份认证层,用于实现单点登录(SSO) | 是认证协议,而OAuth2是授权协议 |
id_token | JWT格式的ID令牌,包含用户身份信息 | 由授权服务器签发,客户端验证 |
response_type | OIDC响应类型 | id_token 或 code,code 用于混合流 |
userinfo_endpoint | 获取用户详细信息 | GET /userinfo |
| claims | id_token 中包含的声明(如 sub、name、email) | sub 是用户唯一标识 |
issuer (iss) | 签发者标识 | 如 https://accounts.google.com |
audience (aud) | 令牌接收方(客户端ID) | 客户端必须验证 |
expiration (exp) | 过期时间戳 | 必须验证 |
nonce | 随机数,防止重放攻击 | 请求时发送,id_token中返回,需比对 |
| discovery | 自动发现OIDC元数据 | .well-known/openid-configuration |
⚠️ 注意:OIDC = OAuth2 + ID Token + UserInfo Endpoint + Discovery + Dynamic Registration;若需用户身份认证(如第三方登录),应使用OIDC而非纯OAuth2。
第五章:OAuth2 实战开发
5.1 使用 Spring Security OAuth2 搭建授权服务器
| 方法/配置 | 用途 | 注意事项 |
|---|---|---|
@EnableAuthorizationServer | 启用OAuth2授权服务器功能 | 需配合 @Configuration 使用(旧版Spring Security OAuth) |
AuthorizationServerConfigurerAdapter | 配置客户端详情、令牌服务、端点 | Spring Security OAuth 2.0+ 已弃用,推荐使用 Spring Security 5.7+ 的新模型 |
client_id | 定义客户端ID | 客户端需预注册 |
client_secret | 客户端密钥 | 建议使用 BCrypt 编码存储 |
authorizedGrantTypes | 允许的授权类型 | 支持:authorization_code, password, client_credentials, implicit;根据客户端类型配置 |
scopes | 客户端可请求的权限范围 | 明确声明,避免通配符 |
redirectUris | 注册回调地址 | 必须精确匹配,防止重定向攻击 |
TokenStore | 定义令牌存储方式 | 可选:InMemoryTokenStore, JdbcTokenStore, JwtTokenStore;JWT 可无状态验证 |
JwtAccessTokenConverter | 将令牌转换为JWT格式 | 设置签名密钥(signingKey),使用强密钥(如RSA) |
/oauth/authorize | 用户授权端点 | 浏览器访问此URL开始流程,需登录用户 |
/oauth/token | 获取令牌端点 | 返回 access_token, refresh_token |
/oauth/check_token | 供资源服务器验证令牌 | 需配置 checkTokenAccess,适用于远程验证 |
代码示例:
@Configuration
@EnableAuthorizationServer
public class AuthServerConfig extends AuthorizationServerConfigurerAdapter {
@Override
public void configure(ClientDetailsServiceConfigurer clients) throws Exception {
clients.inMemory()
.withClient("client123")
.secret(passwordEncoder().encode("secret"))
.authorizedGrantTypes("authorization_code", "refresh_token")
.scopes("read", "write")
.redirectUris("https://client.com/cb");
}
}
@Bean
public TokenStore tokenStore() {
return new JwtTokenStore(accessTokenConverter());
}
@Bean
public JwtAccessTokenConverter accessTokenConverter() {
JwtAccessTokenConverter converter = new JwtAccessTokenConverter();
converter.setSigningKey("signing-key");
return converter;
}
// 请求示例
GET /oauth/authorize?response_type=code&client_id=client123&...
POST /oauth/token
POST /oauth/check_token
⚠️ 注意:Spring Security OAuth 项目已归档,新项目应使用 Spring Security 5.7+ 的内置 OAuth2 Authorization Server 或 Spring Authorization Server(独立项目)。
5.2 使用 Spring Security 实现资源服务器
| 方法/配置 | 用途 | 注意事项 |
|---|---|---|
@EnableResourceServer | 启用资源服务器(旧版) | 已弃用 |
@EnableWebSecurity + @EnableGlobalMethodSecurity | 新版安全配置 | 结合 OAuth2 Resource Server |
spring.security.oauth2.resourceserver.jwt.issuer-uri | 指定JWT签发者URI | 自动发现JWK Set URI |
spring.security.oauth2.resourceserver.jwt.jwk-set-uri | 指定JWK密钥集URI | 手动指定 |
HttpSecurity.authorizeRequests() | 配置访问控制规则 | 使用 .oauth2ResourceServer() 配置JWT验证 |
@PreAuthorize | 方法级权限控制 | 需启用 @EnableGlobalMethodSecurity(prePostEnabled = true) |
| Bearer Token 解析 | 请求头携带令牌 | 资源服务器自动解析并验证JWT签名,依赖 issuer-uri 或 jwk-set-uri |
| 401 响应 | 令牌无效时返回 | 客户端应处理并刷新令牌 |
代码示例:
@Configuration
@EnableResourceServer
public class ResourceServerConfig extends ResourceServerConfigurerAdapter { }
@Configuration
@EnableWebSecurity
public class SecurityConfig {
// 新版安全配置,结合 OAuth2 Resource Server
}
# application.yml
spring:
security:
oauth2:
resourceserver:
jwt:
issuer-uri: https://auth.example.com
# 或手动指定
# jwk-set-uri: https://auth.example.com/.well-known/jwks.json
http.authorizeRequests()
.antMatchers("/api/public").permitAll()
.antMatchers("/api/private").authenticated();
@PreAuthorize("hasAuthority('SCOPE_read')")
public String getData() { ... }
# Bearer Token 在请求头中
Authorization: Bearer <token>
# 401 响应示例
WWW-Authenticate: Bearer error="invalid_token"
⚠️ 注意:推荐使用
spring-boot-starter-oauth2-resource-server依赖,通过配置而非注解实现资源服务器。
5.3 前端应用集成 OAuth2(单页应用SPA)
| 方法/参数 | 用途 | 注意事项 |
|---|---|---|
| 授权码 + PKCE | 现代SPA推荐模式 | 避免使用隐式模式 |
redirect_uri | 接收授权码的回调页面 | 需在授权服务器注册 |
state | 防CSRF | 生成并存储在sessionStorage,回调时验证 |
code | 从回调URL提取授权码 | 获取后立即清除URL中的code |
| token request | 用授权码换令牌 | 必须包含 code_verifier |
存储 access_token | 客户端存储令牌 | 使用 sessionStorage 而非 localStorage(会话级),防XSS |
| 请求API | 调用资源服务器 | 所有请求携带Bearer令牌 |
| 刷新令牌 | 令牌过期处理 | 后台静默刷新或跳转登录,避免频繁弹窗 |
代码示例:
# SPA 授权码 + PKCE 流程
1. 生成 code_verifier 和 code_challenge
2. 重定向至 /authorize
3. 获取 code
4. 用 code + code_verifier 换 token
// 从回调URL提取授权码
const urlParams = new URLSearchParams(window.location.search);
const code = urlParams.get('code');
// 用授权码换令牌
fetch('/token', {
method: 'POST',
body: new URLSearchParams({
grant_type: 'authorization_code',
code: code,
redirect_uri: 'https://spa.com/callback',
code_verifier: codeVerifier
})
});
// 存储令牌到 sessionStorage
sessionStorage.setItem('access_token', token);
// 请求API时携带Bearer令牌
fetch('/api/data', {
headers: { 'Authorization': 'Bearer ' + token }
});
⚠️ 注意:SPA是公共客户端,不能存储
client_secret,必须使用 PKCE;避免在前端暴露长期令牌。
5.4 第三方登录集成(如微信、GitHub、Google)
| 平台 | 授权端点 | 令牌端点 | 用户信息端点 | scope 示例 | 注意事项 |
|---|---|---|---|---|---|
https://accounts.google.com/o/oauth2/v2/auth | https://oauth2.googleapis.com/token | https://www.googleapis.com/oauth2/v3/userinfo | openid email profile | 支持OIDC,使用 authorization code flow | |
| GitHub | https://github.com/login/oauth/authorize | https://github.com/login/oauth/access_token | https://api.github.com/user | user repo | 不支持OIDC,access_token 用于API调用 |
| 微信(开放平台) | https://open.weixin.qq.com/connect/qrconnect | https://api.weixin.qq.com/sns/oauth2/access_token | https://api.weixin.qq.com/sns/userinfo | snsapi_login | 需企业资质;移动端使用不同端点 |
| 参数 | 用途 | 注意事项 |
|---|---|---|
scope | 请求权限 | 权限越小越好 |
client_id | 应用ID | 在开发者平台申请 |
client_secret | 应用密钥 | 服务端安全存储,严禁在前端暴露 |
redirect_uri | 回调地址 | 必须精确匹配注册地址 |
| 获取用户信息 | 获取用户身份 | GET /userinfo + access_token,解析JSON响应获取 openid, email, name 等;不同平台字段名不同 |
代码示例:
# scope 请求示例
Google: email profile
GitHub: user
# 获取用户信息
GET /userinfo + access_token
⚠️ 注意:第三方登录本质是客户端模式,你的应用是客户端,用户授权给你的应用访问第三方资源(如用户资料);用户信息必须由你的后端通过access_token调用第三方API获取,而非前端传递。
第六章:OAuth2 安全最佳实践
6.1 常见安全威胁与防范(如令牌泄露、重定向攻击)
| 威胁类型 | 说明 | 防范措施 | 注意事项 |
|---|---|---|---|
| 授权码拦截 | 攻击者截获授权码并抢先兑换令牌 | 使用 PKCE(强制 code_verifier) | 授权码有效期应极短(<5分钟) |
| 重定向URI攻击 | 攻击者注册恶意回调地址窃取code或token | 严格校验 redirect_uri 是否精确匹配注册地址 | 禁止通配符或宽松匹配 |
| CSRF 攻击 | 诱导用户在已登录状态下完成授权 | 使用 state 参数并验证其一致性 | state 应为高熵随机字符串 |
| 令牌泄露 | access_token 被日志、网络嗅探或XSS窃取 | 使用 HTTPS;短期令牌;避免日志记录;安全存储 | 一旦泄露,立即撤销 |
| 刷新令牌滥用 | refresh_token 被窃取用于长期访问 | 使用滚动刷新(每次刷新都发新refresh_token,旧的失效);绑定客户端 | refresh_token 应可撤销 |
| 客户端伪装 | 伪造客户端ID请求令牌 | 验证 client_id 和 client_secret(机密客户端) | 公共客户端依赖PKCE和重定向URI绑定 |
| 范围提升 | 请求超出用户授权的scope | 授权服务器应强制用户确认scope;资源服务器验证scope | 遵循最小权限原则 |
6.2 HTTPS 的必要性
| 场景 | 说明 | 注意事项 |
|---|---|---|
| 所有通信 | 授权、令牌交换、API调用等所有环节 | 必须使用 HTTPS |
| 令牌传输 | access_token, refresh_token, code | Bearer Token 明文传输,依赖TLS加密 |
| 重定向 | 包含 code 或 token 的重定向 | 防止中间人劫持 |
| Cookie 传输 | 存储 session 或 token | 设置 Secure 和 HttpOnly 标志 |
| 开发环境 | 即使本地开发 | 使用自签名证书或 localhost(现代浏览器视为安全) |
⚠️ 注意:OAuth2 要求所有端点必须通过 HTTPS 暴露(localhost 除外),否则存在严重安全风险。
6.3 客户端凭证管理
| 凭证类型 | 存储位置 | 访问方式 | 注意事项 |
|---|---|---|---|
client_id | 配置文件、环境变量 | 明文(公开) | 可暴露,但需防止伪造 |
client_secret | 环境变量、密钥管理服务(如AWS KMS、Hashicorp Vault) | 服务端读取,不硬编码 | 严禁提交到代码仓库;严禁在前端(JS、App)中暴露 |
| 机密客户端 | Web应用后端 | 可安全存储 secret | 如传统Web应用 |
| 公共客户端 | SPA、移动App、桌面应用 | 无法安全存储 secret | 必须使用 PKCE 替代 client_secret |
| 凭证轮换 | 定期更换 client_secret | 更新后同步到所有服务 | 提供备用密钥平滑过渡 |
⚠️ 注意:公共客户端不应拥有
client_secret,依赖 PKCE 和重定向URI绑定实现安全。
6.4 令牌存储与传输安全
| 环节 | 安全策略 | 注意事项 |
|---|---|---|
| 传输 | HTTPS + Authorization: Bearer 头 | 防止中间人攻击;避免URL参数或Body中传递 |
| 存储(服务端) | access_token:内存或缓存(如Redis);refresh_token:加密数据库存储 | refresh_token 需与 client_id, user_id 绑定 |
| 存储(浏览器) | access_token:sessionStorage(短期) | SPA中优先使用内存变量;避免 localStorage(XSS风险) |
| 存储(移动App) | Android: Keystore;iOS: Keychain | 使用系统安全存储机制 |
| 日志记录 | 禁止记录 access_token 和 refresh_token | 敏感信息脱敏 |
| 过期策略 | access_token:短期(如1小时);refresh_token:长期但可撤销 | 减少泄露影响窗口 |
| 撤销机制 | 提供 /revoke 端点;支持用户主动登出 | refresh_token 应可被服务器撤销 |
| 绑定 | refresh_token 绑定客户端IP、设备指纹(可选) | 增加窃取后使用难度,但可能影响用户体验 |
⚠️ 注意:最小化令牌生命周期和权限是核心安全原则;优先使用短期
access_token+ 可撤销refresh_token组合。
第七章:MVP实践
7.1 项目文件概览
| 文件/模块 | 用途 | 内容 | 关键功能 |
|---|---|---|---|
server.js(主应用入口文件) | 初始化并配置整个应用程序的核心文件 | Express 服务器设置;使用 Passport.js 配置 GitHub OAuth2;会话管理中间件;路由定义(/, /auth/github, /dashboard, /logout);缺失环境变量的错误处理 | 在指定端口启动服务器;处理 GitHub OAuth 认证流程;管理用户会话 |
package.json(项目配置文件) | 定义项目元数据、依赖项和脚本命令 | 项目名称、版本、描述;依赖项(express、passport、passport-github2 等);开发依赖(jest、supertest、nodemon);脚本命令(npm start、npm run dev、npm test) | 管理项目依赖;提供运行、开发和测试的命令 |
utils/oauth-utils.js(OAuth 工具函数) | 提供 OAuth2 相关的辅助函数 | generateStateToken():生成随机令牌以防止 CSRF 攻击;validateState():在认证过程中验证 state 令牌 | 增强安全性;提供可复用的 OAuth 相关工具函数 |
test/oauth.test.js(OAuth 功能测试文件) | 测试 OAuth2 认证流程及相关功能 | 模拟 GitHub 策略用于测试;测试用例包括:OAuth 流程启动、仪表盘访问(已认证 vs 未认证)、登出功能、工具函数测试 | 验证 OAuth2 实现是否正确工作 |
jest.config.js(Jest 配置文件) | 配置 Jest 测试框架 | 测试环境设置;测试文件匹配规则;覆盖率报告配置 | 控制测试的执行方式和结果输出 |
views/index.ejs(首页模板) | 渲染应用的首页界面 | 首页 HTML 结构;CSS 样式;GitHub 登录按钮 | 提供用户发起 OAuth2 登录的界面 |
views/dashboard.ejs(仪表盘模板) | 渲染已认证用户的仪表盘页面 | 显示用户个人资料信息;登出按钮 | 提供认证后的用户界面 |
.env(环境变量文件) | 存储代码之外的敏感配置信息 | GitHub OAuth 凭据(GITHUB_CLIENT_ID、GITHUB_CLIENT_SECRET);会话密钥(SESSION_SECRET);服务器端口(PORT) | 保护敏感数据安全;允许不修改代码即可调整配置 |
附加说明:
- 应用采用简化版的 MVC(模型-视图-控制器)架构。
- Passport.js 负责 OAuth2 认证逻辑。
- Express-session 管理用户会话。
- EJS 模板 提供服务端渲染的视图。
- 整体结构实现了具备完善安全措施、测试覆盖和配置管理的 GitHub OAuth2 认证系统。
7.2 server.js 主应用入口文件
const dotenv = require('dotenv');
dotenv.config();
const express = require('express');
const passport = require('passport');
const session = require('express-session');
const GitHubStrategy = require('passport-github2').Strategy;
const app = express();
const PORT = process.env.PORT || 3000;
// Configure session middleware
app.use(session({
secret: process.env.SESSION_SECRET || 'your-session-secret-key-change-this-in-production',
resave: false,
saveUninitialized: false,
cookie: { secure: false } // Set to true if using HTTPS
}));
// Initialize Passport
app.use(passport.initialize());
app.use(passport.session());
// Check if required environment variables are set
if (!process.env.GITHUB_CLIENT_ID || !process.env.GITHUB_CLIENT_SECRET) {
console.error('Error: Missing GitHub OAuth credentials');
console.error('Please set GITHUB_CLIENT_ID and GITHUB_CLIENT_SECRET in your .env file');
console.error('Follow the instructions in README.md to create a GitHub OAuth App');
process.exit(1);
}
// GitHub OAuth Strategy Configuration
passport.use(new GitHubStrategy({
clientID: process.env.GITHUB_CLIENT_ID,
clientSecret: process.env.GITHUB_CLIENT_SECRET,
callbackURL: "/auth/github/callback"
}, function(accessToken, refreshToken, profile, done) {
// Save user data to session
return done(null, {
id: profile.id,
username: profile.username,
displayName: profile.displayName,
email: profile.emails ? profile.emails[0].value : null,
avatarUrl: profile.photos ? profile.photos[0].value : null
});
}));
// Serialize user for session storage
passport.serializeUser(function(user, done) {
done(null, user);
});
// Deserialize user from session storage
passport.deserializeUser(function(obj, done) {
done(null, obj);
});
// Set EJS as template engine
app.set('view engine', 'ejs');
// Routes
app.get('/', (req, res) => {
res.render('index', { user: req.user });
});
// GitHub OAuth Login Route
app.get('/auth/github',
passport.authenticate('github', { scope: ['user:email'] })
);
// GitHub OAuth Callback Route
app.get('/auth/github/callback',
passport.authenticate('github', { failureRedirect: '/' }),
function(req, res) {
// Successful authentication, redirect home.
res.redirect('/dashboard');
}
);
// Dashboard route (protected)
app.get('/dashboard', (req, res) => {
if (!req.user) {
return res.redirect('/');
}
res.render('dashboard', { user: req.user });
});
// Logout route
app.get('/logout', (req, res) => {
req.logout((err) => {
if (err) {
console.error(err);
}
});
res.redirect('/');
});
// Start server
app.listen(PORT, () => {
console.log(`Server running on port ${PORT}`);
});
7.3 package.json 项目配置文件
{
"name": "github-oauth-mvp",
"version": "1.0.0",
"description": "MVP project for GitHub OAuth2 authentication",
"main": "server.js",
"scripts": {
"start": "node server.js",
"dev": "nodemon server.js",
"test": "jest --passWithNoTests"
},
"dependencies": {
"express": "^4.18.2",
"passport": "^0.6.0",
"passport-github2": "^0.1.12",
"express-session": "^1.17.3",
"dotenv": "^16.3.1",
"ejs": "^3.1.9",
"cookie-parser": "^1.4.6"
},
"devDependencies": {
"nodemon": "^3.0.1",
"jest": "^29.7.0",
"supertest": "^6.3.3"
}
}
7.4 utils/oauth-utils.js OAuth 工具函数
const crypto = require('crypto');
/**
* Generate a random state token for CSRF protection
* @returns {string} Random state token
*/
function generateStateToken() {
return crypto.randomBytes(32).toString('hex');
}
/**
* Validate the state parameter from OAuth callback
* @param {string} receivedState - State received from OAuth provider
* @param {string} expectedState - Expected state from session
* @returns {boolean} Whether the state is valid
*/
function validateState(receivedState, expectedState) {
// Return false if either value is null, undefined, or not matching
if (receivedState == null || expectedState == null) {
return false;
}
return receivedState === expectedState;
}
module.exports = {
generateStateToken,
validateState
};
7.5 test/oauth.test.js OAuth 功能测试文件
// This file contains Jest tests and should only be run with Jest
// Do not import this file directly into your application code
// Use CommonJS syntax for Jest compatibility
const request = require('supertest');
const express = require('express');
const session = require('express-session');
const passport = require('passport');
// Properly mock the GitHub strategy with a working implementation
jest.mock('passport-github2', () => {
class MockGitHubStrategy {
constructor(options, verify) {
this.name = 'github';
this._verify = verify;
this._options = options;
}
authenticate(req, options) {
// Check if we're handling the callback or the initial request
if (req.path === '/auth/github/callback') {
// Simulate successful authentication with mock user data for callback
const mockProfile = {
id: '12345',
username: 'testuser',
displayName: 'Test User',
emails: [{ value: 'test@example.com' }],
photos: [{ value: 'https://example.com/avatar.jpg' }]
};
// Call the verify function with mock data
this._verify(null, null, mockProfile, (err, user) => {
if (err) {
req.login = () => {};
this.fail();
return;
}
// Simulate login with mock user
req.user = user;
req.session.passport = { user: user };
// Redirect to success URL
req.res.redirect('/dashboard');
});
} else {
// For initial request, redirect to GitHub's authorization URL (like real strategy)
const scope = Array.isArray(options.scope) ? options.scope.join(',') : options.scope;
const githubAuthUrl = `https://github.com/login/oauth/authorize?client_id=${this._options.clientID}&scope=${scope}`;
req.res.redirect(githubAuthUrl);
}
}
}
return {
Strategy: MockGitHubStrategy
};
});
// Only run test setup if Jest globals are available
if (typeof jest !== 'undefined') {
// Create a simplified version of our app for testing
const app = express();
// Use in-memory session store for testing
app.use(session({
secret: 'test-secret',
resave: false,
saveUninitialized: false,
store: new (require('express-session').MemoryStore)()
}));
app.use(passport.initialize());
app.use(passport.session());
// Mock serialization/deserialization for testing
passport.serializeUser((user, done) => {
done(null, user);
});
passport.deserializeUser((obj, done) => {
done(null, obj);
});
// Use the mocked GitHub strategy
const GitHubStrategy = require('passport-github2').Strategy;
passport.use(new GitHubStrategy(
{ clientID: 'test-client-id', clientSecret: 'test-client-secret', callbackURL: '/auth/github/callback' },
(accessToken, refreshToken, profile, done) => {
return done(null, {
id: profile.id,
username: profile.username,
displayName: profile.displayName,
email: profile.emails ? profile.emails[0].value : null,
avatarUrl: profile.photos ? profile.photos[0].value : null
});
}
));
// Simplified routes for testing
app.get('/', (req, res) => {
res.status(200).json({ message: 'Home page', user: req.user || null });
});
app.get('/auth/github', (req, res, next) => {
res.redirect('https://github.com/login/oauth/authorize?client_id=test-client-id&redirect_uri=http://localhost:3000/auth/github/callback&scope=user:email');
});
app.get('/auth/github/callback',
passport.authenticate('github', { failureRedirect: '/' }),
(req, res) => {
res.redirect('/dashboard');
}
);
app.get('/dashboard', (req, res) => {
if (!req.user) {
return res.status(401).json({ error: 'Unauthorized' });
}
res.status(200).json({ message: 'Dashboard', user: req.user });
});
app.get('/logout', (req, res) => {
req.logout((err) => {
if (err) {
console.error('Logout error:', err);
}
});
res.redirect('/');
});
describe('GitHub OAuth2 MVP Tests', () => {
describe('GET /', () => {
it('should return home page with no user when not authenticated', async () => {
const response = await request(app)
.get('/')
.expect(200);
expect(response.body.message).toBe('Home page');
expect(response.body.user).toBeNull();
});
});
describe('GET /auth/github', () => {
it('should initiate GitHub OAuth flow', async () => {
const response = await request(app)
.get('/auth/github')
.redirects(0);
expect(response.status).toBe(302);
expect(response.headers.location).toContain('github.com/login/oauth/authorize');
});
});
describe('GET /dashboard', () => {
it('should return 401 when not authenticated', async () => {
const response = await request(app)
.get('/dashboard')
.expect(401);
expect(response.body.error).toBe('Unauthorized');
});
it('should return dashboard when authenticated', async () => {
const agent = request.agent(app);
await agent
.get('/auth/github/callback?code=test-code')
.expect(302);
const response = await agent
.get('/dashboard')
.expect(200);
expect(response.body.message).toBe('Dashboard');
expect(response.body.user).toBeDefined();
expect(response.body.user.username).toBe('testuser');
});
});
describe('GET /logout', () => {
it('should handle logout request', async () => {
const response = await request(app)
.get('/logout')
.expect(302);
expect(response.headers.location).toBe('/');
});
});
});
// Additional unit tests for utility functions
const { generateStateToken, validateState } = require('../utils/oauth-utils');
describe('OAuth Utilities', () => {
describe('generateStateToken', () => {
it('should generate a random state token', () => {
const token1 = generateStateToken();
const token2 = generateStateToken();
expect(token1).toBeDefined();
expect(token2).toBeDefined();
expect(token1).not.toBe(token2);
expect(typeof token1).toBe('string');
expect(token1.length).toBeGreaterThan(10);
});
});
describe('validateState', () => {
it('should return true for matching states', () => {
const state = 'test-state-123';
const result = validateState(state, state);
expect(result).toBe(true);
});
it('should return false for non-matching states', () => {
const result = validateState('state1', 'state2');
expect(result).toBe(false);
});
it('should return false for null/undefined states', () => {
expect(validateState(null, 'valid')).toBe(false);
expect(validateState('valid', null)).toBe(false);
expect(validateState(undefined, 'valid')).toBe(false);
expect(validateState('valid', undefined)).toBe(false);
expect(validateState(null, null)).toBe(false);
expect(validateState(undefined, undefined)).toBe(false);
});
});
});
} else {
console.log('This test file should only be run with Jest. Use `npm test` to run tests.');
}
7.6 jest.config.js Jest 配置文件
module.exports = {
testEnvironment: 'node',
testMatch: ['**/test/**/*.test.js'],
collectCoverageFrom: [
'server.js',
'utils/**/*.js',
'!test/**/*.js'
],
coverageDirectory: 'coverage',
coverageReporters: ['text', 'lcov', 'html']
};
7.7 views/index.ejs 首页模板
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>GitHub OAuth2 MVP</title>
<style>
body {
font-family: Arial, sans-serif;
max-width: 800px;
margin: 0 auto;
padding: 20px;
background-color: #f5f5f5;
}
.container {
background: white;
padding: 30px;
border-radius: 8px;
box-shadow: 0 2px 10px rgba(0,0,0,0.1);
}
h1 {
color: #333;
text-align: center;
}
.login-btn {
display: block;
width: 200px;
margin: 20px auto;
padding: 12px;
background-color: #24292e;
color: white;
text-align: center;
text-decoration: none;
border-radius: 4px;
font-weight: bold;
}
.login-btn:hover {
background-color: #2c3e50;
}
.user-info {
text-align: center;
margin-top: 20px;
padding: 20px;
background-color: #e8f4fd;
border-radius: 4px;
}
.logout-btn {
display: inline-block;
margin-top: 10px;
padding: 8px 16px;
background-color: #e74c3c;
color: white;
text-decoration: none;
border-radius: 4px;
}
</style>
</head>
<body>
<div class="container">
<h1>GitHub OAuth2 MVP</h1>
<% if (user) { %>
<div class="user-info">
<h2>Welcome, <%= user.displayName %>!</h2>
<img src="<%= user.avatarUrl %>" alt="Avatar" width="100" height="100" style="border-radius: 50%;">
<p><strong>Username:</strong> <%= user.username %></p>
<p><strong>ID:</strong> <%= user.id %></p>
<% if (user.email) { %>
<p><strong>Email:</strong> <%= user.email %></p>
<% } %>
<a href="/dashboard" class="login-btn">Go to Dashboard</a>
<br>
<a href="/logout" class="logout-btn">Logout</a>
</div>
<% } else { %>
<p>Please log in with GitHub to continue.</p>
<a href="/auth/github" class="login-btn">Login with GitHub</a>
<% } %>
</div>
</body>
</html>
7.8 views/dashboard.ejs 仪表盘模板
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Dashboard - GitHub OAuth2 MVP</title>
<style>
body {
font-family: Arial, sans-serif;
max-width: 800px;
margin: 0 auto;
padding: 20px;
background-color: #f5f5f5;
}
.container {
background: white;
padding: 30px;
border-radius: 8px;
box-shadow: 0 2px 10px rgba(0,0,0,0.1);
}
h1 {
color: #333;
text-align: center;
}
.user-profile {
text-align: center;
margin-bottom: 30px;
padding: 20px;
background-color: #e8f4fd;
border-radius: 4px;
}
.profile-image {
width: 120px;
height: 120px;
border-radius: 50%;
object-fit: cover;
margin-bottom: 15px;
}
.logout-btn {
display: inline-block;
margin-top: 10px;
padding: 8px 16px;
background-color: #e74c3c;
color: white;
text-decoration: none;
border-radius: 4px;
}
.back-btn {
display: inline-block;
margin-top: 10px;
padding: 8px 16px;
background-color: #3498db;
color: white;
text-decoration: none;
border-radius: 4px;
}
</style>
</head>
<body>
<div class="container">
<h1>Dashboard</h1>
<div class="user-profile">
<img src="<%= user.avatarUrl %>" alt="Profile" class="profile-image">
<h2>Hello, <%= user.displayName %>!</h2>
<p><strong>Username:</strong> <%= user.username %></p>
<p><strong>ID:</strong> <%= user.id %></p>
<% if (user.email) { %>
<p><strong>Email:</strong> <%= user.email %></p>
<% } %>
<p><strong>Status:</strong> Authenticated with GitHub</p>
</div>
<div style="text-align: center;">
<a href="/" class="back-btn">Back to Home</a>
<a href="/logout" class="logout-btn">Logout</a>
</div>
<div style="margin-top: 30px; padding: 20px; background-color: #f8f9fa; border-radius: 4px;">
<h3>Authentication Info</h3>
<p>This page is protected and only accessible to authenticated users.</p>
<p>Your GitHub account information has been successfully retrieved through OAuth2.</p>
</div>
</div>
</body>
</html>
7.9 .env 环境变量文件
PORT=3000
#GITHUB_CLIENT_ID=your_github_client_id_here
GITHUB_CLIENT_ID=Iv23lipCJRBsxVLvTOLw
#GITHUB_CLIENT_SECRET=your_github_client_secret_here
GITHUB_CLIENT_SECRET=788fc3ee48168102f8e0afa47f42bd92df691c50
SESSION_SECRET=your-session-secret-key-change-this-in-production
NODE_ENV=development