Skip to content

授权码 + 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. 配置客户端 ​

go
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空必须精确匹配,未登记的地址会被拒绝
RequirePkcetrue未带 code_challenge 的请求被拒绝
AllowPlainTextPkcefalse默认只接受 S256
RequireClientSecrettrue公共客户端要显式设为 false
RequireConsentfalse为 true 时会先跳转到宿主的同意页
AuthorizationCodeLifetime300 秒授权码有效期
IdentityTokenLifetime300 秒id_token 有效期
AllowOfflineAccessfalse见 刷新令牌

2. 告诉 wicket 登录页在哪里 ​

必须显式设置 LoginUrl

UserInteractionOptions 中 LoginUrl、LoginReturnUrlParameter、LogoutUrl 的默认值是空字符串(常量 /account/login、returnUrl 存在,但没有被设为默认值)。不设置时,未登录用户会被重定向到一个无效地址(实测为 /connect/?=%2Fconnect%2Fauthorize%2Fcallback...)。

go
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)

wicket 通过名为 wicket(常量 wicket.DefaultCookieAuthenticationScheme)的认证方案判断用户是否已登录。v0.1.4 没有内置该方案的处理器,宿主需要:

  1. 实现 auth.Handler,并同时实现 auth.SignInHandler / auth.SignOutHandler;
  2. 用 cfg.SetAuthenticationHandlerProvider 注册处理器工厂;
  3. 用 *authcore.SchemeProvider 的 AddScheme 注册方案名。

下面是实验程序使用的开发用实现:Cookie 中只放一个随机 ID,登录态存在进程内存里。生产环境请换成带签名/加密的 Cookie 或共享会话存储。

go
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() 之前):

go
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))。

bash
V=dBjftJeZ4CVP-mB92K27uhbUJU1p1r_wW1gFWFOEjXk
C=$(printf %s "$V" | openssl dgst -sha256 -binary | base64 | tr '+/' '-_' | tr -d '=')
# C = E9Melhoa2OwvFrEMTJguCHaoeK1t8URWbuGJSstw-cM

浏览器访问:

text
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_methodRequirePkce 时是推荐 S256
state建议原样带回,防 CSRF
nonce建议写入 id_token,防重放
response_mode否默认 query,另支持 form_post、fragment

用户未登录时,wicket 返回 302 到宿主登录页(实测):

text
HTTP/1.1 302 Found
Location: http://localhost:5000/account/login?returnUrl=%2Fconnect%2Fauthorize%2Fcallback%3Fclient_id%3Dweb-app%26code_challenge%3DE9Melhoa...%26state%3Dxyz

returnUrl 指向 wicket 的 /connect/authorize/callback,携带原始请求参数。

5. 宿主登录页 ​

登录页要做三件事:用 InteractionService 校验 returnUrl 并取出授权上下文、验证用户、用 auth.Service.SignIn 写入登录态后跳回 returnUrl。服务都从请求作用域解析(需要外层挂了 hosting.ScopeMiddleware):

go
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 确认已登录,重定向到客户端(实测):

text
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. 用授权码换取令牌 ​

bash
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 载荷:

json
{
  "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"
}

访问令牌载荷:

json
{
  "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 ​

bash
curl -s -H "Authorization: Bearer $ACCESS_TOKEN" http://localhost:5000/connect/userinfo

默认的 ProfileService 不提供任何资料,实测只返回 {"sub":"alice"}。要返回 name 等资料,注入自己的实现:

go
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 读取详情:

go
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,否则只展示错误、不跳转。

相关阅读 ​

以 Apache License 2.0 发布 · 文档内容 © 2026 Wicket 作者