Adopt upstream owpengram/owpengram-server's branding.Config/Configure/ Current in place of the old package-level constants. This is the piece the earlier merge attempt was blocked on (internal/branding failed to import during that merge). The default identity is unchanged -- every existing ProductName/ProductUsername/... default still reads "OwpenGram" -- so this is a pure capability add: nothing currently calls Configure, and every call site now reads the current snapshot via a function instead of a compile-time constant. Five string templates that concatenated branding.ProductName into a `const` had to become `var`, since a func call is no longer a valid const operand. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
275 lines
8.5 KiB
Go
275 lines
8.5 KiB
Go
// Package passkey 实现 passkey(WebAuthn)登录与管理的业务编排:生成挑战选项、
|
|
// 验证注册 attestation、验证登录 assertion,并维护凭据/挑战持久化。
|
|
// 密码学验证委托 internal/webauthn;auth_key 绑定委托 auth.Service。
|
|
package passkey
|
|
|
|
import (
|
|
"context"
|
|
"crypto/rand"
|
|
"fmt"
|
|
"strconv"
|
|
"strings"
|
|
"time"
|
|
|
|
"telesrv/internal/branding"
|
|
"telesrv/internal/domain"
|
|
"telesrv/internal/store"
|
|
"telesrv/internal/webauthn"
|
|
)
|
|
|
|
const (
|
|
defaultChallengeTTL = 5 * time.Minute
|
|
challengeSize = 32
|
|
)
|
|
|
|
// Service 提供 passkey 登录/注册业务。
|
|
type Service struct {
|
|
creds store.PasskeyStore
|
|
challenges store.PasskeyChallengeStore
|
|
rpID string
|
|
rpName string
|
|
allowedOrigins []string
|
|
dcID int
|
|
challengeTTL time.Duration
|
|
now func() time.Time
|
|
}
|
|
|
|
// Option 调整 passkey 服务可选项。
|
|
type Option func(*Service)
|
|
|
|
// WithRPName 设置 relying-party 显示名(authenticator UI 展示)。
|
|
func WithRPName(name string) Option { return func(s *Service) { s.rpName = name } }
|
|
|
|
// WithAllowedOrigins 设置允许的 WebAuthn origin 白名单;为空表示不强校验 origin
|
|
// (服务端通常不预知 Android apk-key-hash origin)。
|
|
func WithAllowedOrigins(origins []string) Option {
|
|
return func(s *Service) { s.allowedOrigins = append([]string(nil), origins...) }
|
|
}
|
|
|
|
// WithChallengeTTL 覆盖挑战有效期。
|
|
func WithChallengeTTL(d time.Duration) Option {
|
|
return func(s *Service) {
|
|
if d > 0 {
|
|
s.challengeTTL = d
|
|
}
|
|
}
|
|
}
|
|
|
|
// WithClock 注入时钟(测试用)。
|
|
func WithClock(now func() time.Time) Option {
|
|
return func(s *Service) {
|
|
if now != nil {
|
|
s.now = now
|
|
}
|
|
}
|
|
}
|
|
|
|
// NewService 创建 passkey 服务。rpID 为 WebAuthn relying-party id(域名);dcID 写入
|
|
// user_handle 供客户端 DC 重路由(本部署单 DC,仅做回显)。
|
|
func NewService(creds store.PasskeyStore, challenges store.PasskeyChallengeStore, rpID string, dcID int, opts ...Option) *Service {
|
|
s := &Service{
|
|
creds: creds,
|
|
challenges: challenges,
|
|
rpID: rpID,
|
|
rpName: branding.ProductName(),
|
|
dcID: dcID,
|
|
challengeTTL: defaultChallengeTTL,
|
|
now: time.Now,
|
|
}
|
|
for _, opt := range opts {
|
|
opt(s)
|
|
}
|
|
return s
|
|
}
|
|
|
|
func (s *Service) randomChallenge() ([]byte, error) {
|
|
b := make([]byte, challengeSize)
|
|
if _, err := rand.Read(b); err != nil {
|
|
return nil, err
|
|
}
|
|
return b, nil
|
|
}
|
|
|
|
func (s *Service) userHandle(userID int64) string {
|
|
return fmt.Sprintf("%d:%d", s.dcID, userID)
|
|
}
|
|
|
|
func parseUserHandle(h string) (int64, error) {
|
|
h = strings.TrimSpace(h)
|
|
if h == "" {
|
|
return 0, domain.ErrPasskeyUserHandleInvalid
|
|
}
|
|
if i := strings.LastIndex(h, ":"); i >= 0 {
|
|
h = h[i+1:]
|
|
}
|
|
id, err := strconv.ParseInt(h, 10, 64)
|
|
if err != nil || id == 0 {
|
|
return 0, domain.ErrPasskeyUserHandleInvalid
|
|
}
|
|
return id, nil
|
|
}
|
|
|
|
// InitRegistration 为已登录用户生成注册选项(creation options DataJSON)。
|
|
func (s *Service) InitRegistration(ctx context.Context, userID int64, displayName string) ([]byte, error) {
|
|
if s == nil || s.creds == nil || s.challenges == nil || userID == 0 {
|
|
return nil, domain.ErrPasskeyInvalid
|
|
}
|
|
challenge, err := s.randomChallenge()
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
if err := s.challenges.SavePasskeyChallenge(ctx, challenge, domain.PasskeyChallenge{
|
|
Purpose: domain.PasskeyChallengeRegister,
|
|
UserID: userID,
|
|
ExpiresAt: s.now().Add(s.challengeTTL).Unix(),
|
|
}, s.challengeTTL); err != nil {
|
|
return nil, err
|
|
}
|
|
existing, err := s.creds.ListPasskeysByUser(ctx, userID)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
exclude := make([][]byte, 0, len(existing))
|
|
for _, c := range existing {
|
|
exclude = append(exclude, c.CredentialID)
|
|
}
|
|
if displayName == "" {
|
|
displayName = fmt.Sprintf("user%d", userID)
|
|
}
|
|
return webauthn.BuildRegistrationOptions(webauthn.RegistrationParams{
|
|
RPID: s.rpID,
|
|
RPName: s.rpName,
|
|
UserID: []byte(s.userHandle(userID)),
|
|
UserName: displayName,
|
|
UserDisplay: displayName,
|
|
Challenge: challenge,
|
|
ExcludeIDs: exclude,
|
|
})
|
|
}
|
|
|
|
// Register 验证注册 attestation 并持久化凭据。credentialID 为原始字节(rpc 已 base64url 解码)。
|
|
func (s *Service) Register(ctx context.Context, userID int64, credentialID, clientDataJSON, attestationObject []byte, name string) (domain.PasskeyCredential, error) {
|
|
if s == nil || s.creds == nil || s.challenges == nil || userID == 0 {
|
|
return domain.PasskeyCredential{}, domain.ErrPasskeyInvalid
|
|
}
|
|
challenge, err := webauthn.ChallengeFromClientData(clientDataJSON)
|
|
if err != nil {
|
|
return domain.PasskeyCredential{}, domain.ErrPasskeyInvalid
|
|
}
|
|
meta, found, err := s.challenges.ConsumePasskeyChallenge(ctx, challenge)
|
|
if err != nil {
|
|
return domain.PasskeyCredential{}, err
|
|
}
|
|
if !found || meta.Purpose != domain.PasskeyChallengeRegister || meta.UserID != userID {
|
|
return domain.PasskeyCredential{}, domain.ErrPasskeyChallengeInvalid
|
|
}
|
|
cred, err := webauthn.VerifyRegistration(webauthn.VerifyRegistrationInput{
|
|
ClientDataJSON: clientDataJSON,
|
|
AttestationObject: attestationObject,
|
|
RPID: s.rpID,
|
|
Challenge: challenge,
|
|
AllowedOrigins: s.allowedOrigins,
|
|
})
|
|
if err != nil {
|
|
return domain.PasskeyCredential{}, fmt.Errorf("%w: %v", domain.ErrPasskeyInvalid, err)
|
|
}
|
|
// credentialID 来自 TL 的 ID/RawID;以 authData 内的 credID 为准(防客户端不一致)。
|
|
now := s.now().Unix()
|
|
record := domain.PasskeyCredential{
|
|
CredentialID: cred.ID,
|
|
UserID: userID,
|
|
PublicKey: cred.COSEPublicKey,
|
|
SignCount: cred.SignCount,
|
|
AAGUID: cred.AAGUID,
|
|
Name: strings.TrimSpace(name),
|
|
CreatedAt: now,
|
|
}
|
|
if err := s.creds.InsertPasskey(ctx, record); err != nil {
|
|
return domain.PasskeyCredential{}, err
|
|
}
|
|
return record, nil
|
|
}
|
|
|
|
// InitLogin 生成登录选项(request options DataJSON,discoverable,无 allowCredentials)。
|
|
func (s *Service) InitLogin(ctx context.Context) ([]byte, error) {
|
|
if s == nil || s.challenges == nil {
|
|
return nil, domain.ErrPasskeyInvalid
|
|
}
|
|
challenge, err := s.randomChallenge()
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
if err := s.challenges.SavePasskeyChallenge(ctx, challenge, domain.PasskeyChallenge{
|
|
Purpose: domain.PasskeyChallengeLogin,
|
|
ExpiresAt: s.now().Add(s.challengeTTL).Unix(),
|
|
}, s.challengeTTL); err != nil {
|
|
return nil, err
|
|
}
|
|
return webauthn.BuildLoginOptions(webauthn.LoginParams{RPID: s.rpID, Challenge: challenge})
|
|
}
|
|
|
|
// FinishLogin 验证登录 assertion,成功返回该 passkey 所属用户 id(auth_key 绑定由调用方完成)。
|
|
func (s *Service) FinishLogin(ctx context.Context, credentialID, clientDataJSON, authenticatorData, signature []byte, userHandle string) (int64, error) {
|
|
if s == nil || s.creds == nil || s.challenges == nil {
|
|
return 0, domain.ErrPasskeyInvalid
|
|
}
|
|
handleUserID, err := parseUserHandle(userHandle)
|
|
if err != nil {
|
|
return 0, err
|
|
}
|
|
cred, found, err := s.creds.GetPasskeyByCredentialID(ctx, credentialID)
|
|
if err != nil {
|
|
return 0, err
|
|
}
|
|
if !found {
|
|
return 0, domain.ErrPasskeyNotFound
|
|
}
|
|
// 交叉校验:credential 所属用户须与 user_handle 一致。
|
|
if cred.UserID != handleUserID {
|
|
return 0, domain.ErrPasskeyInvalid
|
|
}
|
|
challenge, err := webauthn.ChallengeFromClientData(clientDataJSON)
|
|
if err != nil {
|
|
return 0, domain.ErrPasskeyInvalid
|
|
}
|
|
meta, found, err := s.challenges.ConsumePasskeyChallenge(ctx, challenge)
|
|
if err != nil {
|
|
return 0, err
|
|
}
|
|
if !found || meta.Purpose != domain.PasskeyChallengeLogin {
|
|
return 0, domain.ErrPasskeyChallengeInvalid
|
|
}
|
|
newCount, err := webauthn.VerifyAssertion(webauthn.VerifyAssertionInput{
|
|
COSEPublicKey: cred.PublicKey,
|
|
ClientDataJSON: clientDataJSON,
|
|
AuthenticatorData: authenticatorData,
|
|
Signature: signature,
|
|
RPID: s.rpID,
|
|
Challenge: challenge,
|
|
AllowedOrigins: s.allowedOrigins,
|
|
StoredSignCount: cred.SignCount,
|
|
})
|
|
if err != nil {
|
|
return 0, fmt.Errorf("%w: %v", domain.ErrPasskeyInvalid, err)
|
|
}
|
|
if err := s.creds.UpdatePasskeyUsage(ctx, cred.CredentialID, newCount, s.now().Unix()); err != nil {
|
|
return 0, err
|
|
}
|
|
return cred.UserID, nil
|
|
}
|
|
|
|
// List 返回用户的全部 passkey(管理页)。
|
|
func (s *Service) List(ctx context.Context, userID int64) ([]domain.PasskeyCredential, error) {
|
|
if s == nil || s.creds == nil || userID == 0 {
|
|
return nil, nil
|
|
}
|
|
return s.creds.ListPasskeysByUser(ctx, userID)
|
|
}
|
|
|
|
// Delete 删除用户的某个 passkey。credentialID 为原始字节。
|
|
func (s *Service) Delete(ctx context.Context, userID int64, credentialID []byte) (bool, error) {
|
|
if s == nil || s.creds == nil || userID == 0 {
|
|
return false, nil
|
|
}
|
|
return s.creds.DeletePasskey(ctx, userID, credentialID)
|
|
}
|