外部登录(Google / Facebook)
本页说明如何让浏览器用户通过 Google 或 Facebook 账号登录到嵌入了 wicket 的授权服务器:装配 handler、触发跳转、处理回调,以及失败时如何收尾。
先看结论:宿主要做的四件事
wicket 内置了 Google、Facebook 的 OAuth 2.0 授权码 handler,但 v0.1.4 中容器不会替你把它们配好。要让外部登录真正跑通,宿主需要:
- 提供带凭据的 handler:通过
cfg.SetAuthenticationHandlerProvider(方案名, 工厂)交给容器。容器自带的默认 handler 读取的选项里ClientId/ClientSecret为空,且没有留给宿主写入的配置钩子。 - 注册认证方案:容器启动时静态方案表是空的。用
auth.NewRequestHandlerScheme注册,这样中间件才会在回调路径上调用该 handler。 - 注册并实现 cookie 方案:回调成功后用户要被签入一个 cookie 方案(默认
wicket)。容器里该方案只有一个不支持签入的占位 handler,且默认不在方案表中——宿主既要实现 handler,也要注册方案。漏掉注册时回调会“看似成功”却没有建立任何会话,见 回调如何被处理。 - (建议)设置失败路径:
cfg.SetExternalFailurePath(...),否则回调失败时返回 500。
完整装配示例
以下代码已在 v0.1.4 上编译并实测:访问 /login/google 得到指向 Google 授权端点的 302,回调失败时 302 到失败页。
import (
"log/slog"
"github.com/gonewx/godi"
wicket "github.com/gonewx/wicket"
"github.com/gonewx/wicket/auth"
"github.com/gonewx/wicket/auth/google"
oauthcore "github.com/gonewx/wicket/auth/oauth/core"
"github.com/gonewx/wicket/di"
optspkg "github.com/gonewx/wicket/di/options"
"github.com/gonewx/wicket/dp"
"github.com/gonewx/wicket/services"
)
// fixedMonitor 把一份装配好的选项交给 handler(handler 通过 OptionsMonitor 取选项)。
type fixedMonitor[T optspkg.Entity[T]] struct{ opts T }
func (m fixedMonitor[T]) CurrentValue() (T, error) { return m.opts, nil }
func (m fixedMonitor[T]) Get(string) (T, error) { return m.opts, nil }
// newGoogleOptions 构造选项并补齐运行期依赖:state 的保护格式与 backchannel HTTP 客户端。
func newGoogleOptions(protection dp.DataProtectionProvider, clientID, clientSecret string) (*google.Options, error) {
o := google.NewOptions()
o.SetClientId(clientID)
o.SetClientSecret(clientSecret)
o.SetDataProtectionProvider(protection)
if err := oauthcore.NewPostConfigureOptions[*google.Options](protection).
PostConfigure(google.AuthenticationScheme, o); err != nil {
return nil, err
}
return o, o.Validate()
}
func wireGoogle(root *di.Root, logger *slog.Logger, protection dp.DataProtectionProvider) error {
cfg := root.ServerConfig()
opts, err := newGoogleOptions(protection, "<client-id>", "<client-secret>")
if err != nil {
return err // 启动期就暴露缺失的凭据
}
cfg.SetAuthenticationHandlerProvider(wicket.GoogleAuthenticationScheme, func(p godi.Provider) auth.Handler {
return google.NewHandler(fixedMonitor[*google.Options]{opts}, logger,
godi.GetRequired[services.SystemClock](p))
})
cfg.SetExternalFailurePath("/account/external-failure")
// 让中间件在 /signin-google 上调用这个 handler
return root.Ptr_SchemeProvider().AddScheme(
auth.NewRequestHandlerScheme(wicket.GoogleAuthenticationScheme, "google"))
}wireGoogle 应放在装配阶段、cfg.Freeze() 之前,并且必须早于 srv.UseWicket():失败路径是在 UseWicket() 构建中间件时读取的,之后再设置不会生效。其余装配(签名密钥、客户端、hosting.ScopeMiddleware 包裹)与 快速开始 相同。
protection 参数见下文 state 的保护。
触发跳转
在宿主自己的路由里,从请求作用域取出 auth.Service 并对外部方案发起 challenge。RedirectUri 决定回调成功后用户被送回哪里,留空则回到发起 challenge 的原地址:
app.HandleFunc("/login/google", func(w http.ResponseWriter, r *http.Request) {
svc := godi.GetRequired[auth.Service](hosting.ScopeFrom(r.Context()))
props := auth.NewProperties()
back := r.URL.Query().Get("returnUrl") // 登录后要回到的地址
// 只接受站内路径,防止开放重定向
if !strings.HasPrefix(back, "/") || strings.HasPrefix(back, "//") || strings.Contains(back, `\`) {
back = "/"
}
props.SetRedirectUri(&back)
if err := svc.Challenge(r, w, wicket.GoogleAuthenticationScheme, props); err != nil {
http.Error(w, "login unavailable", http.StatusInternalServerError)
}
})handler 会写出一个名为 .wicket.Correlation.<随机值> 的关联 cookie(防 CSRF),并 302 到授权端点,查询串含 client_id、redirect_uri(由当前请求的 scheme 与 host 拼出,路径为回调路径)、response_type=code、scope、state。
回调如何被处理
wicket 的认证中间件在每个请求上依次执行:
- 对默认认证方案(
wicket,若已注册)做一次 Authenticate; - 枚举所有“处理请求”的方案,询问其 handler:当前路径是否等于它的
CallbackPath; - 命中的 handler 校验 state 与关联 cookie、用 code 换令牌、请求用户信息端点、按 claim 映射生成身份;
- 调用
SignIn(SignInScheme, ...)把身份写入 cookie 方案——SignInScheme未设置时使用默认签入方案(DefaultSignInScheme为空,回落到DefaultScheme即wicket); - 302 回
RedirectUri,未设置时回/。
第 4 步的结果取决于签入方案的状态。下表是 v0.1.4 上用桩令牌端点与桩用户信息端点走完整回调的实测:
签入方案 wicket 的状态 | 回调响应 | 是否建立会话 |
|---|---|---|
| 未注册到方案表 | 302 /welcome(看似成功) | 否——v0.1.4 在这种情况下既不报错也不记日志 |
| 已注册,但沿用容器的占位 handler | 302 到失败页,日志 sign-in is unsupported | 否 |
已注册,且由宿主提供 auth.SignInHandler | 302 /welcome | 是:SignIn 收到 NameIdentifier = g-123、Email = a@example.com 的身份 |
第一行是静默失败
没有注册签入方案时,用户被正常送回目标页,但实际上并未登录,随后进入授权端点又会被要求登录。上线前请确认回调后 cookie 确实写出。
用户在 wicket 会话中登录后,再次进入 /connect/authorize 就会被视为已认证,wicket 按正常授权码流程继续。
宿主必须提供的 cookie handler
两个 cookie 方案名定义在根包:
| 常量 | 值 | 用途 |
|---|---|---|
wicket.DefaultCookieAuthenticationScheme | wicket | 主会话,也是 DefaultScheme |
wicket.ExternalCookieAuthenticationScheme | wicket.external | 外部登录的临时身份(若你选择先签入这里,再在自己的页面里完成账号关联) |
容器对这两个名字注册的默认 handler 不支持 challenge、forbid,也不实现签入。宿主需实现 auth.SignInHandler(以及登出时用到的 auth.SignOutHandler),并用 SetAuthenticationHandlerProvider 交给容器,再用 auth.NewScheme 把方案注册进 root.Ptr_SchemeProvider():
type SignInHandler interface {
Initialize(*http.Request, http.ResponseWriter, *auth.Scheme) error
Authenticate() (*auth.Result, error)
Challenge(*auth.Properties) error
Forbid(*auth.Properties) error
SignIn(*claims.ClaimsPrincipal, *auth.Properties) error
}v0.1.4 的非测试代码中没有任何 SignIn 方法的 handler 实现(只有转发调用的认证服务本身),这就是必须由宿主提供的原因。
实现要点:SignIn 把身份序列化后加密写入 cookie,Authenticate 读取并还原为 auth.Success(ticket);cookie 内容必须有机密性和完整性保护(见下一节)。若想先签入 wicket.external,对外部方案的选项调用 SetSignInScheme(wicket.ExternalCookieAuthenticationScheme)。
state 的保护
OAuth 往返中的 state 由 StateDataFormat 序列化并经 DataProtectionProvider 保护。
默认的 DataProtectionProvider 不适用于生产
容器里注册的默认 dp.DataProtectionProvider(root.DataProtectionProvider())仅供开发使用,不适用于生产。生产环境请实现自己的 dp.DataProtectionProvider(例如基于 AES-GCM 并支持密钥轮换),并按上面示例传入选项,不要用容器默认值。
dp 包的接口很小:
type DataProtectionProvider interface{ Create(purpose string) DataProtector }
type DataProtector interface {
Create(purpose string) DataProtector
Protect(plaintext []byte) []byte
Unprotect(protected []byte) ([]byte, error)
}多实例部署时,所有实例必须使用同一套密钥,否则在 A 实例发起、在 B 实例回调的请求会因 state 无法解开而失败。
Google 选项
| 项 | 值 |
|---|---|
| 方案名 | Google(wicket.GoogleAuthenticationScheme,亦即 google.AuthenticationScheme) |
| 授权端点 | https://accounts.google.com/o/oauth2/v2/auth |
| 令牌端点 | https://oauth2.googleapis.com/token |
| 用户信息端点 | https://www.googleapis.com/oauth2/v2/userinfo |
| 默认回调路径 | /signin-google |
| 默认 scope | openid、profile、email |
google.Options 在通用 OAuth 选项之上只多一个字段 AccessType *string:为 nil(默认)时不发送 access_type 参数;需要 Google 返回 refresh token 时设为 "offline"。
默认 claim 映射(左侧为 wicket 中的 claim 类型,右侧为用户信息 JSON 的字段):
| claim 类型 | JSON 字段 |
|---|---|
claims.NameIdentifier | id |
claims.Name(值为 name) | name |
claims.GivenName | given_name |
claims.Surname | family_name |
urn:google:profile | link |
claims.Email | email |
NameIdentifier、GivenName 等常量的值是较长的 URI 形式(如 http://schemas.xmlsoap.org/ws/2005/05/identity/claims/nameidentifier),读取时请用 claims 包里的常量而不是手写字符串。
Facebook 选项
| 项 | 值 |
|---|---|
| 方案名 | Facebook(wicket.FacebookAuthenticationScheme) |
| 授权端点 | https://www.facebook.com/v11.0/dialog/oauth |
| 令牌端点 | https://graph.facebook.com/v11.0/oauth/access_token |
| 用户信息端点 | https://graph.facebook.com/v11.0/me |
| 默认回调路径 | /signin-facebook |
| 默认 scope | email |
| 默认请求字段 | name、email、first_name、last_name |
Facebook 使用“应用编号/应用密钥”的叫法,SetAppId / SetAppSecret 只是 SetClientId / SetClientSecret 的别名。其他字段:
SendAppSecretProof bool:默认true,请求用户信息时附带appsecret_proof。AddFields(...):追加要从 Graph API 拉取的字段(自动去重),如birthday、gender、locale、location。
Graph API 版本号硬编码为 v11.0。如需其他版本,可用 SetAuthorizationEndpoint / SetTokenEndpoint / SetUserInformationEndpoint 覆盖。
默认 claim 映射:id→NameIdentifier、email→Email、name→Name、first_name→GivenName、last_name→Surname、birthday→DateOfBirth、gender→Gender、locale→Locality,以及 urn:facebook: 前缀的 age_range_min、age_range_max(取自 age_range.min/max)、middle_name、link、location(取自 location.name)、timezone。
Validate 不会自动执行
facebook.Options.Validate() 会检查 AppId、AppSecret 非空,google.Options 继承的 Validate() 会检查 client id/secret、端点和回调路径——但 v0.1.4 的容器不会自动调用它们。请像示例中那样在装配时自己调用,让配置错误在启动期暴露。
通用选项速查
Google 与 Facebook 的选项都嵌入了 oauthcore.Options(再嵌入远程认证的通用选项),常用的 setter:
| 方法 | 默认 | 说明 |
|---|---|---|
SetClientId / SetClientSecret | 空 | 提供者分配的凭据 |
SetCallbackPath | 各提供者自定 | 回调路径,需与提供者后台登记的一致 |
AddScope / SetScope / ClearScope | 各提供者自定 | 请求的权限 |
SetUsePkce | false | 是否在授权请求中使用 PKCE |
SetSignInScheme | 空(回落到默认签入方案) | 回调成功后签入哪个 cookie 方案 |
SetSaveTokens | false | 是否把外部令牌存入认证属性 |
SetRemoteAuthenticationTimeout | 15 分钟 | 整个往返的时限 |
SetBackchannelTimeout | 60 秒 | 换令牌、取用户信息的 HTTP 超时 |
SetBackchannelHttpHandler | 无 | 自定义传输层(代理、测试替身) |
AddClaimAction / RemoveClaimAction / ClearClaimActions | 见上表 | 调整 claim 映射 |
SetDataProtectionProvider / SetStateDataFormat | 见上节 | state 的保护方式 |
事件钩子挂在 oauthcore.Events 上:OnCreatingTicket(生成身份前,可补充 claims)与 OnRedirectToAuthorizationEndpoint(发出跳转前)。
失败处理
回调路径上的任何失败——state 缺失或无效、关联 cookie 不匹配、换令牌失败、用户信息请求失败——都会让中间件终止请求,而不是落到宿主的 404:
是否设置 SetExternalFailurePath | 响应 |
|---|---|
已设置,如 /account/external-failure | 302 Location: /account/external-failure?error=external_login_failed |
| 未设置 | 500,正文为 External login failed. |
external_login_failed(常量 auth.ExternalLoginFailedError)是唯一的错误码,不区分具体原因;细节只写进日志(request-handler processing failed),不会出现在响应里。失败页由宿主实现,读取 error 参数后展示自己的提示即可。
实测(伪造 state 访问回调):
GET /signin-google?code=abc&state=bogus
→ 302 /account/external-failure?error=external_login_failed
日志: request-handler processing failed scheme=Google error="... The oauth state was missing or invalid."