This commit is contained in:
+198
-14
@@ -16,6 +16,7 @@ import (
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"gamertan.com/web/access"
|
||||
"gamertan.com/web/auth"
|
||||
"gamertan.com/web/authwebauthn"
|
||||
)
|
||||
@@ -25,6 +26,8 @@ const DefaultCodeCount = 10
|
||||
var (
|
||||
ErrCodeNotFound = errors.New("authrecovery: recovery code not found")
|
||||
ErrGrantNotFound = errors.New("authrecovery: recovery grant not found")
|
||||
ErrAssistedNotFound = errors.New("authrecovery: assisted recovery grant not found")
|
||||
ErrAssistedDenied = errors.New("authrecovery: assisted recovery is not authorized")
|
||||
ErrPasskeyUnavailable = errors.New("authrecovery: passkey recovery is unavailable")
|
||||
)
|
||||
|
||||
@@ -49,6 +52,47 @@ type PasskeyRepository interface {
|
||||
CompletePasskeyRecovery(context.Context, PasskeyCompletion) error
|
||||
}
|
||||
|
||||
// AssistedGrant is the digest-only authority created by an organization
|
||||
// owner after a human recovery review. The plaintext token is returned once
|
||||
// to the caller and never persisted or audited.
|
||||
type AssistedGrant struct {
|
||||
Digest [32]byte
|
||||
OrganizationID, UserID string
|
||||
IssuedByUserID string
|
||||
CreatedAt, ExpiresAt time.Time
|
||||
}
|
||||
|
||||
// AssistedIssue binds an owner-reviewed recovery to one organization member.
|
||||
// Reason is deliberately bounded and must not contain credential material.
|
||||
type AssistedIssue struct {
|
||||
OrganizationID, ActorUserID, TargetUserID, RequestID, Reason string
|
||||
}
|
||||
|
||||
// AssistedRepository provides the two transactional boundaries for delegated
|
||||
// recovery. Issuance invalidates all existing account authenticators and
|
||||
// sessions while recording both identity and organization-visible audits.
|
||||
// Completion consumes the grant exactly once and installs the replacement
|
||||
// password, passkey, and recovery-code set atomically.
|
||||
type AssistedRepository interface {
|
||||
Repository
|
||||
IssueAssistedRecovery(context.Context, AssistedGrant, string, auth.AuditEvent, access.AuditEvent) (auth.User, error)
|
||||
AssistedRecoveryGrant(context.Context, [32]byte, time.Time) (AssistedGrant, auth.User, error)
|
||||
CompleteAssistedRecovery(context.Context, AssistedCompletion) error
|
||||
}
|
||||
|
||||
// AssistedCompletion contains only the password hash, public passkey
|
||||
// credential, digest-only recovery codes, and secret-free audit material.
|
||||
type AssistedCompletion struct {
|
||||
GrantDigest [32]byte
|
||||
Credential authwebauthn.Credential
|
||||
PasswordHash string
|
||||
RecoveryDigests [][32]byte
|
||||
PasskeyAudit auth.AuditEvent
|
||||
RecoveryAudit auth.AuditEvent
|
||||
AccessAudit access.AuditEvent
|
||||
CompletedAt time.Time
|
||||
}
|
||||
|
||||
// Passkeys performs recovery-bound WebAuthn registration ceremonies.
|
||||
type Passkeys interface {
|
||||
BeginRecoveryRegistration(context.Context, string, string, []byte) (authwebauthn.BeginResult, error)
|
||||
@@ -78,21 +122,25 @@ type PasswordVerifier interface {
|
||||
}
|
||||
|
||||
type Options struct {
|
||||
Random io.Reader
|
||||
Now func() time.Time
|
||||
CodeCount int
|
||||
GrantLifetime time.Duration
|
||||
Passkeys Passkeys
|
||||
Random io.Reader
|
||||
Now func() time.Time
|
||||
CodeCount int
|
||||
GrantLifetime time.Duration
|
||||
AssistedGrantLifetime time.Duration
|
||||
OwnerRole string
|
||||
Passkeys Passkeys
|
||||
}
|
||||
|
||||
type Service struct {
|
||||
repository Repository
|
||||
passwords PasswordVerifier
|
||||
random io.Reader
|
||||
now func() time.Time
|
||||
count int
|
||||
grantTTL time.Duration
|
||||
passkeys Passkeys
|
||||
repository Repository
|
||||
passwords PasswordVerifier
|
||||
random io.Reader
|
||||
now func() time.Time
|
||||
count int
|
||||
grantTTL time.Duration
|
||||
assistedTTL time.Duration
|
||||
ownerRole string
|
||||
passkeys Passkeys
|
||||
}
|
||||
|
||||
func New(repository Repository, passwords PasswordVerifier, options Options) (*Service, error) {
|
||||
@@ -111,10 +159,120 @@ func New(repository Repository, passwords PasswordVerifier, options Options) (*S
|
||||
if options.GrantLifetime == 0 {
|
||||
options.GrantLifetime = 10 * time.Minute
|
||||
}
|
||||
if options.CodeCount < 5 || options.CodeCount > 20 || options.GrantLifetime < 2*time.Minute || options.GrantLifetime > 30*time.Minute {
|
||||
if options.AssistedGrantLifetime == 0 {
|
||||
options.AssistedGrantLifetime = 15 * time.Minute
|
||||
}
|
||||
if options.CodeCount < 5 || options.CodeCount > 20 || options.GrantLifetime < 2*time.Minute || options.GrantLifetime > 30*time.Minute || options.AssistedGrantLifetime < 5*time.Minute || options.AssistedGrantLifetime > 30*time.Minute || options.OwnerRole != "" && !safeRole(options.OwnerRole) {
|
||||
return nil, errors.New("authrecovery: invalid recovery policy")
|
||||
}
|
||||
return &Service{repository: repository, passwords: passwords, random: options.Random, now: options.Now, count: options.CodeCount, grantTTL: options.GrantLifetime, passkeys: options.Passkeys}, nil
|
||||
return &Service{repository: repository, passwords: passwords, random: options.Random, now: options.Now, count: options.CodeCount, grantTTL: options.GrantLifetime, assistedTTL: options.AssistedGrantLifetime, ownerRole: options.OwnerRole, passkeys: options.Passkeys}, nil
|
||||
}
|
||||
|
||||
// IssueAssistedRecovery creates one owner-authorized, single-use recovery
|
||||
// token. The repository immediately invalidates the target's previous
|
||||
// password, passkeys, recovery codes, sessions, and pending ceremonies so the
|
||||
// reviewed recovery cannot race an older authenticator.
|
||||
func (service *Service) IssueAssistedRecovery(ctx context.Context, input AssistedIssue) (auth.User, string, error) {
|
||||
repository, ok := service.repository.(AssistedRepository)
|
||||
input.OrganizationID = strings.TrimSpace(input.OrganizationID)
|
||||
input.ActorUserID = strings.TrimSpace(input.ActorUserID)
|
||||
input.TargetUserID = strings.TrimSpace(input.TargetUserID)
|
||||
input.RequestID = strings.TrimSpace(input.RequestID)
|
||||
input.Reason = strings.TrimSpace(input.Reason)
|
||||
if !ok || service.passkeys == nil || service.ownerRole == "" {
|
||||
return auth.User{}, "", ErrPasskeyUnavailable
|
||||
}
|
||||
if !opaqueID(input.OrganizationID) || !opaqueID(input.ActorUserID) || !opaqueID(input.TargetUserID) || input.RequestID != "" && !opaqueID(input.RequestID) || len(input.Reason) < 8 || len(input.Reason) > 240 || strings.ContainsAny(input.Reason, "\x00\r\n") {
|
||||
return auth.User{}, "", errors.New("authrecovery: invalid assisted recovery request")
|
||||
}
|
||||
raw, err := token(service.random, 32)
|
||||
if err != nil {
|
||||
return auth.User{}, "", err
|
||||
}
|
||||
now := service.now().UTC()
|
||||
grant := AssistedGrant{Digest: sha256.Sum256([]byte(raw)), OrganizationID: input.OrganizationID, UserID: input.TargetUserID, IssuedByUserID: input.ActorUserID, CreatedAt: now, ExpiresAt: now.Add(service.assistedTTL)}
|
||||
authAuditID, err := token(service.random, 18)
|
||||
if err != nil {
|
||||
return auth.User{}, "", err
|
||||
}
|
||||
accessAuditID, err := token(service.random, 18)
|
||||
if err != nil {
|
||||
return auth.User{}, "", err
|
||||
}
|
||||
summary := "Owner-assisted account recovery issued after human review. Reason: " + input.Reason
|
||||
authAudit := auth.AuditEvent{ID: authAuditID, ActorUserID: input.ActorUserID, Action: "auth.assisted-recovery.issue", ResourceType: "user", ResourceID: input.TargetUserID, RequestID: input.RequestID, Summary: summary, CreatedAt: now}
|
||||
accessAudit := access.AuditEvent{ID: accessAuditID, OrganizationID: input.OrganizationID, ActorUserID: input.ActorUserID, Action: "access.account-recovery.issue", ResourceType: "user", ResourceID: input.TargetUserID, RequestID: input.RequestID, Summary: summary, CreatedAt: now}
|
||||
user, err := repository.IssueAssistedRecovery(ctx, grant, service.ownerRole, authAudit, accessAudit)
|
||||
if err != nil {
|
||||
return auth.User{}, "", err
|
||||
}
|
||||
return user, raw, nil
|
||||
}
|
||||
|
||||
// BeginAssistedPasskey starts a replacement ceremony without issuing a normal
|
||||
// session. The grant remains reusable for ceremony restart until completion or
|
||||
// expiry; only completion consumes it.
|
||||
func (service *Service) BeginAssistedPasskey(ctx context.Context, rawGrant, label string) (authwebauthn.BeginResult, error) {
|
||||
repository, ok := service.repository.(AssistedRepository)
|
||||
if !ok || service.passkeys == nil {
|
||||
return authwebauthn.BeginResult{}, ErrPasskeyUnavailable
|
||||
}
|
||||
digest, err := grantDigest(rawGrant)
|
||||
if err != nil {
|
||||
return authwebauthn.BeginResult{}, ErrAssistedNotFound
|
||||
}
|
||||
_, user, err := repository.AssistedRecoveryGrant(ctx, digest, service.now().UTC())
|
||||
if err != nil {
|
||||
return authwebauthn.BeginResult{}, err
|
||||
}
|
||||
return service.passkeys.BeginRecoveryRegistration(ctx, user.ID, label, []byte(rawGrant))
|
||||
}
|
||||
|
||||
// FinishAssistedRecovery consumes a reviewed grant only inside the transaction
|
||||
// that installs every replacement credential and both audit trails. No normal
|
||||
// session is issued; the recovered user signs in with the new credentials.
|
||||
func (service *Service) FinishAssistedRecovery(ctx context.Context, rawGrant, ceremonyToken, password string, response []byte) (PasskeyFinishResult, error) {
|
||||
repository, ok := service.repository.(AssistedRepository)
|
||||
if !ok || service.passkeys == nil {
|
||||
return PasskeyFinishResult{}, ErrPasskeyUnavailable
|
||||
}
|
||||
digest, err := grantDigest(rawGrant)
|
||||
if err != nil {
|
||||
return PasskeyFinishResult{}, ErrAssistedNotFound
|
||||
}
|
||||
grant, user, err := repository.AssistedRecoveryGrant(ctx, digest, service.now().UTC())
|
||||
if err != nil {
|
||||
return PasskeyFinishResult{}, err
|
||||
}
|
||||
passwordHash, err := auth.HashPasswordWithRandom(password, service.random)
|
||||
if err != nil {
|
||||
return PasskeyFinishResult{}, err
|
||||
}
|
||||
codes, digests, err := GenerateCodeSet(service.random, service.count)
|
||||
if err != nil {
|
||||
return PasskeyFinishResult{}, err
|
||||
}
|
||||
credential, err := service.passkeys.FinishRecoveryRegistration(ctx, ceremonyToken, []byte(rawGrant), response, func(commitContext context.Context, verified authwebauthn.Credential, passkeyAudit auth.AuditEvent) error {
|
||||
if verified.UserID != user.ID {
|
||||
return errors.New("authrecovery: assisted recovery identity mismatch")
|
||||
}
|
||||
completedAt := service.now().UTC()
|
||||
recoveryAuditID, auditErr := token(service.random, 18)
|
||||
if auditErr != nil {
|
||||
return auditErr
|
||||
}
|
||||
accessAuditID, auditErr := token(service.random, 18)
|
||||
if auditErr != nil {
|
||||
return auditErr
|
||||
}
|
||||
recoveryAudit := auth.AuditEvent{ID: recoveryAuditID, ActorUserID: user.ID, Action: "auth.assisted-recovery.complete", ResourceType: "user", ResourceID: user.ID, Summary: "Owner-assisted recovery replaced the password, passkeys, recovery codes, and sessions.", CreatedAt: completedAt}
|
||||
accessAudit := access.AuditEvent{ID: accessAuditID, OrganizationID: grant.OrganizationID, ActorUserID: user.ID, Action: "access.account-recovery.complete", ResourceType: "user", ResourceID: user.ID, Summary: "The organization member completed owner-assisted account recovery.", CreatedAt: completedAt}
|
||||
return repository.CompleteAssistedRecovery(commitContext, AssistedCompletion{GrantDigest: digest, Credential: verified, PasswordHash: passwordHash, RecoveryDigests: digests, PasskeyAudit: passkeyAudit, RecoveryAudit: recoveryAudit, AccessAudit: accessAudit, CompletedAt: completedAt})
|
||||
})
|
||||
if err != nil {
|
||||
return PasskeyFinishResult{}, err
|
||||
}
|
||||
return PasskeyFinishResult{Credential: credential, RecoveryCodes: codes}, nil
|
||||
}
|
||||
|
||||
// ReplaceCodes creates a complete new recovery-code set. Codes are returned
|
||||
@@ -290,3 +448,29 @@ func token(random io.Reader, size int) (string, error) {
|
||||
}
|
||||
return base64.RawURLEncoding.EncodeToString(value), nil
|
||||
}
|
||||
|
||||
func opaqueID(value string) bool {
|
||||
if len(value) < 8 || len(value) > 128 {
|
||||
return false
|
||||
}
|
||||
for _, character := range value {
|
||||
if character == '-' || character == '_' || character >= 'a' && character <= 'z' || character >= 'A' && character <= 'Z' || character >= '0' && character <= '9' {
|
||||
continue
|
||||
}
|
||||
return false
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
func safeRole(value string) bool {
|
||||
if len(value) < 1 || len(value) > 96 {
|
||||
return false
|
||||
}
|
||||
for _, character := range value {
|
||||
if character == '-' || character == '_' || character == '.' || character >= 'a' && character <= 'z' || character >= '0' && character <= '9' {
|
||||
continue
|
||||
}
|
||||
return false
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user