授权码 + PKCE
本页说明有用户参与的标准登录流程:客户端把浏览器重定向到 wicket 的 /connect/authorize,wicket 发现用户未登录时把浏览器交给宿主应用的登录页,登录完成后再回到 wicket 签发授权码,客户端最后在后端用授权码换取令牌。
本页的验证程度
本页的 curl 结果来自一个在 v0.1.4 上实际运行的实验程序:在快速开始示例的基础上加入了下文的开发用 Cookie 处理器和登录页,用 curl 携带 Cookie 走完了整条重定向链。Go 片段均已编译通过。页面里浏览器的行为(表单、跳转)由 curl 模拟。
先看清职责划分
wicket 只实现协议端点,不提供登录界面,也不内置生产可用的登录 Cookie 处理器。v0.1.4 中由宿主负责:
| 宿主负责 | wicket 负责 |
|---|---|
| 登录页、同意页、错误页的 HTML 与路由 | /connect/authorize 参数校验、重定向到这些页面 |
| 校验用户名密码(或接入外部登录) | 授权码签发、PKCE 校验、令牌签发 |
为 wicket 认证方案提供 Cookie 处理器(读写登录态) | 通过该处理器判断“当前用户是否已登录” |
用户资料(ProfileService) | userinfo 端点 |
1. 配置客户端
web := models.NewClient()
web.ClientId = "web-app"
web.ClientSecrets = []*models.Secret{secretOf("web-secret")} // 机密客户端:服务端应用
web.RedirectUris = []string{"http://127.0.0.1:8080/callback"}
web.PostLogoutRedirectUris = []string{"http://127.0.0.1:8080/signed-out"}
web.AllowOfflineAccess = true // 允许申请 offline_access 拿刷新令牌
web.AllowedScopes = []string{"openid", "profile", "orders.read"}
if err := web.SetAllowedGrantTypes(wicket.GrantTypesCode); err != nil {
log.Fatal(err)
}
spa := models.NewClient()
spa.ClientId = "spa"
spa.RequireClientSecret = false // 公共客户端:SPA / 移动端,只靠 PKCE
spa.RedirectUris = []string{"http://127.0.0.1:8080/callback"}
spa.AllowedScopes = []string{"openid", "profile", "orders.read"}
if err := spa.SetAllowedGrantTypes(wicket.GrantTypesCode); err != nil {
log.Fatal(err)
}
cfg.SetIdentityScopes([]*models.IdentityResource{
models.IdentityResourcesOpenId(),
models.IdentityResourcesProfile(),
})secretOf 是一个小工具函数:models.NewSecret() 后把 Value 设为 utils.Sha256(明文)。
与本流程相关的 Client 字段及 NewClient() 默认值:
| 字段 | 默认值 | 说明 |
|---|---|---|
RedirectUris | 空 | 必须精确匹配,未登记的地址会被拒绝 |
RequirePkce | true | 未带 code_challenge 的请求被拒绝 |
AllowPlainTextPkce | false | 默认只接受 S256 |
RequireClientSecret | true | 公共客户端要显式设为 false |
RequireConsent | false | 为 true 时会先跳转到宿主的同意页 |
AuthorizationCodeLifetime | 300 秒 | 授权码有效期 |
IdentityTokenLifetime | 300 秒 | id_token 有效期 |
AllowOfflineAccess | false | 见 刷新令牌 |
2. 告诉 wicket 登录页在哪里
必须显式设置 LoginUrl
UserInteractionOptions 中 LoginUrl、LoginReturnUrlParameter、LogoutUrl 的默认值是空字符串(常量 /account/login、returnUrl 存在,但没有被设为默认值)。不设置时,未登录用户会被重定向到一个无效地址(实测为 /connect/?=%2Fconnect%2Fauthorize%2Fcallback...)。
opts := godi.GetRequired[*options.WicketOptions](root)
opts.UserInteraction.LoginUrl = "/account/login"
opts.UserInteraction.LoginReturnUrlParameter = "returnUrl"
opts.UserInteraction.LogoutUrl = "/account/logout"
// 下面三项已有默认值,按需修改:
// opts.UserInteraction.ConsentUrl = "/consent" (参数名 returnUrl)
// opts.UserInteraction.ErrorUrl = "/home/error" (参数名 errorId)3. 提供登录 Cookie 处理器
wicket 通过名为 wicket(常量 wicket.DefaultCookieAuthenticationScheme)的认证方案判断用户是否已登录。v0.1.4 没有内置该方案的处理器,宿主需要:
- 实现
auth.Handler,并同时实现auth.SignInHandler/auth.SignOutHandler; - 用
cfg.SetAuthenticationHandlerProvider注册处理器工厂; - 用
*authcore.SchemeProvider的AddScheme注册方案名。
下面是实验程序使用的开发用实现:Cookie 中只放一个随机 ID,登录态存在进程内存里。生产环境请换成带签名/加密的 Cookie 或共享会话存储。
type devCookieHandler struct {
r *http.Request
w http.ResponseWriter
scheme *auth.Scheme
session services.UserSession
}
var tickets sync.Map // id -> *claims.ClaimsPrincipal(仅限单进程开发)
func (h *devCookieHandler) Initialize(r *http.Request, w http.ResponseWriter, s *auth.Scheme) error {
h.r, h.w, h.scheme = r, w, s
return nil
}
func (h *devCookieHandler) Authenticate() (*auth.Result, error) {
c, err := h.r.Cookie(h.scheme.Name)
if err != nil {
return auth.NoResult(), nil
}
p, ok := tickets.Load(c.Value)
if !ok {
return auth.NoResult(), nil
}
return &auth.Result{Principal: p.(*claims.ClaimsPrincipal), Properties: auth.NewProperties()}, nil
}
func (h *devCookieHandler) SignIn(p *claims.ClaimsPrincipal, props *auth.Properties) error {
buf := make([]byte, 16)
_, _ = rand.Read(buf)
id := hex.EncodeToString(buf)
tickets.Store(id, p)
http.SetCookie(h.w, &http.Cookie{Name: h.scheme.Name, Value: id, Path: "/",
HttpOnly: true, SameSite: http.SameSiteLaxMode})
// 建立 wicket 的用户会话(启用服务端会话时会写入 session.Store)
_, err := h.session.CreateSessionId(h.r.Context(), p, props)
return err
}
func (h *devCookieHandler) SignOut(*auth.Properties) error {
if c, err := h.r.Cookie(h.scheme.Name); err == nil {
tickets.Delete(c.Value)
}
http.SetCookie(h.w, &http.Cookie{Name: h.scheme.Name, Value: "", Path: "/", MaxAge: -1})
return nil
}
func (h *devCookieHandler) Challenge(*auth.Properties) error { return nil }
func (h *devCookieHandler) Forbid(*auth.Properties) error { return nil }注册(在 cfg.Freeze() 之前):
cfg.SetAuthenticationHandlerProvider(wicket.DefaultCookieAuthenticationScheme,
func(p godi.Provider) auth.Handler {
return &devCookieHandler{session: godi.GetRequired[services.UserSession](p)}
})
if err := godi.GetRequired[*authcore.SchemeProvider](root).AddScheme(
auth.NewScheme(wicket.DefaultCookieAuthenticationScheme, "dev-cookie")); err != nil {
log.Fatal(err)
}漏掉某一步时的表现
- 没有调用
AddScheme:auth.Service.SignIn直接返回nil,不写任何 Cookie。实测登录页跳回/connect/authorize/callback后,又被重定向回登录页。登录“成功”却回到登录页时,先检查这一步。 - 调用了
AddScheme但没有注册处理器:SignIn返回错误sign-in is unsupported(实测)。
4. 发起授权请求
客户端先生成 PKCE 参数:code_verifier 为 43–128 位随机串,code_challenge = BASE64URL(SHA256(code_verifier))。
V=dBjftJeZ4CVP-mB92K27uhbUJU1p1r_wW1gFWFOEjXk
C=$(printf %s "$V" | openssl dgst -sha256 -binary | base64 | tr '+/' '-_' | tr -d '=')
# C = E9Melhoa2OwvFrEMTJguCHaoeK1t8URWbuGJSstw-cM浏览器访问:
GET /connect/authorize
?client_id=web-app
&response_type=code
&scope=openid%20profile%20orders.read%20offline_access
&redirect_uri=http%3A%2F%2F127.0.0.1%3A8080%2Fcallback
&state=xyz
&nonce=n-1
&code_challenge=E9Melhoa2OwvFrEMTJguCHaoeK1t8URWbuGJSstw-cM
&code_challenge_method=S256| 参数 | 必需 | 说明 |
|---|---|---|
client_id、redirect_uri | 是 | redirect_uri 须在 RedirectUris 中 |
response_type | 是 | 授权码流用 code |
scope | 是 | 含 openid 时签发 id_token |
code_challenge、code_challenge_method | RequirePkce 时是 | 推荐 S256 |
state | 建议 | 原样带回,防 CSRF |
nonce | 建议 | 写入 id_token,防重放 |
response_mode | 否 | 默认 query,另支持 form_post、fragment |
用户未登录时,wicket 返回 302 到宿主登录页(实测):
HTTP/1.1 302 Found
Location: http://localhost:5000/account/login?returnUrl=%2Fconnect%2Fauthorize%2Fcallback%3Fclient_id%3Dweb-app%26code_challenge%3DE9Melhoa...%26state%3DxyzreturnUrl 指向 wicket 的 /connect/authorize/callback,携带原始请求参数。
5. 宿主登录页
登录页要做三件事:用 InteractionService 校验 returnUrl 并取出授权上下文、验证用户、用 auth.Service.SignIn 写入登录态后跳回 returnUrl。服务都从请求作用域解析(需要外层挂了 hosting.ScopeMiddleware):
app.HandleFunc("/account/login", func(w http.ResponseWriter, r *http.Request) {
sp := hosting.ScopeFrom(r.Context())
returnUrl := r.URL.Query().Get("returnUrl")
// 只接受 wicket 认得的 returnUrl,防止开放重定向
// (实测 returnUrl=https://evil.example/ 时 req 为 nil,这里返回 400)
req, err := godi.GetRequired[services.InteractionService](sp).
GetAuthorizationContext(r.Context(), returnUrl)
if err != nil || req == nil {
http.Error(w, "invalid returnUrl", http.StatusBadRequest)
return
}
if r.Method == http.MethodGet {
// 渲染登录表单;可展示 req.Client.ClientName、req.LoginHint 等
return
}
subjectID := checkPassword(r) // 宿主自己的用户校验
now := time.Now()
user := wicketmodels.NewLocalUser(wicketmodels.WithLocalUserSubjectId(subjectID))
user.DisplayName = "Alice"
user.AuthenticationTime = &now
user.AuthenticationMethods = []string{"pwd"}
principal, err := user.CreatePrincipal()
if err != nil {
http.Error(w, err.Error(), http.StatusInternalServerError)
return
}
if err := godi.GetRequired[auth.Service](sp).SignIn(r, w,
wicket.DefaultCookieAuthenticationScheme, principal, auth.NewProperties()); err != nil {
http.Error(w, err.Error(), http.StatusInternalServerError)
return
}
http.Redirect(w, r, returnUrl, http.StatusFound)
})浏览器回到 /connect/authorize/callback 后,wicket 确认已登录,重定向到客户端(实测):
HTTP/1.1 302 Found
Location: http://127.0.0.1:8080/callback?code=5907BA0E3F12...&iss=http%3A%2F%2Flocalhost%3A5000&scope=openid+profile+orders.read+offline_access&state=xyz回调中的 iss 参数可用于防混淆攻击,客户端应校验它等于签发者。
登录主体里的声明会进入令牌
CreatePrincipal() 生成的 name、amr、auth_time 等声明在 v0.1.4 中会同时出现在 id_token 和访问令牌里(见下文)。不要往登录主体里放不该出现在令牌中的信息。
6. 用授权码换取令牌
curl -s -u web-app:web-secret \
-d grant_type=authorization_code \
-d code=$CODE \
-d redirect_uri=http://127.0.0.1:8080/callback \
-d code_verifier=$V \
http://localhost:5000/connect/token公共客户端不做 Basic 认证,改为在表单里带 client_id=spa。实测响应包含 access_token、id_token、refresh_token(因为申请了 offline_access)。id_token 载荷:
{
"amr": ["pwd"], "at_hash": "6rs1GDSrwppzB9k22f5OQQ", "aud": ["web-app"],
"auth_time": 1794539156, "exp": 1794539465, "iat": 1794539165,
"iss": "http://localhost:5000", "name": "Alice", "nbf": 1794539165,
"nonce": "n-1", "sub": "alice"
}访问令牌载荷:
{
"amr": ["pwd"], "aud": ["orders"], "auth_time": 1794539156, "client_id": "web-app",
"exp": 1794542765, "iat": 1794539165, "iss": "http://localhost:5000", "name": "Alice",
"nbf": 1794539165, "scope": ["openid", "profile", "orders.read", "offline_access"], "sub": "alice"
}授权码只能用一次,重复兑换返回 invalid_grant;code_verifier 不匹配同样返回 invalid_grant(均已实测)。
7. userinfo
curl -s -H "Authorization: Bearer $ACCESS_TOKEN" http://localhost:5000/connect/userinfo默认的 ProfileService 不提供任何资料,实测只返回 {"sub":"alice"}。要返回 name 等资料,注入自己的实现:
type userProfile struct{ users map[string]map[string]string } // 用宿主用户库代替
func (p *userProfile) GetProfileData(ctx *contexts.ProfileDataRequestContext) {
sub := ""
if c := ctx.Subject.FindFirst("sub"); c != nil {
sub, _ = c.Value.(string)
}
var issued []*claims.Claim
for t, v := range p.users[sub] {
issued = append(issued, claims.NewClaim(claims.WithClaimType(t), claims.WithClaimValue(v)))
}
ctx.AddRequestedClaims(issued) // 只保留本次作用域要求的声明类型
}
func (p *userProfile) IsActive(ctx *contexts.IsActiveContext) {
sub := ""
if c := ctx.Subject.FindFirst("sub"); c != nil {
sub, _ = c.Value.(string)
}
_, ctx.IsActive = p.users[sub]
}
cfg.SetProfileProvider(func() services.ProfileService { return prof })注入后,用户资料为 name、email、website、作用域为 openid profile orders.read 时,实测 userinfo 返回 {"name":"Alice Zhang","sub":"alice","website":"https://alice.example.com"}——email 因为没有申请 email 作用域被过滤掉。IsActive 返回 false 的用户在授权端点会被送回登录页。
8. 错误页
实测以下授权请求错误都会先重定向到宿主错误页 ErrorUrl?errorId=...,而不是直接回到客户端:redirect_uri 未登记、缺少 code_challenge、请求 plain 但未开启 AllowPlainTextPkce、只允许授权码的客户端请求 response_type=token、未开启 AllowOfflineAccess 的客户端请求 offline_access、client_id 不存在。错误页用 GetErrorContext 读取详情:
msg, err := godi.GetRequired[services.InteractionService](hosting.ScopeFrom(r.Context())).
GetErrorContext(r.Context(), r.URL.Query().Get("errorId"))
// msg.Error / msg.ErrorDescription:例如 invalid_request / "code challenge required"
// msg.RedirectUri:redirect_uri 合法时,已拼好回传给客户端的错误地址;为空说明不能回跳不要直接信任错误上下文
v0.1.4 中请不要把 errorId 及其解析出的错误上下文当作保密或可信数据。错误页不要根据 msg.RedirectUri 自动跳转:如需回跳客户端,先确认 msg.RedirectUri(去掉查询串后)属于 msg.ClientId 对应客户端已登记的 RedirectUris,否则只展示错误、不跳转。
相关阅读
- 刷新令牌:
offline_access与刷新令牌的当前行为 - 会话与登出:登出页与
/connect/endsession - 嵌入宿主应用:
hosting.ScopeMiddleware与中间件顺序 - 外部登录(Google / Facebook):在登录页接入第三方登录