auth: publish one-time bootstrap rotation
verify / verify (push) Successful in 3m14s

Publish the reviewed Web Foundations v0.1.0-preview.3 snapshot with cryptographic temporary credentials, explicit forced-rotation state, atomic password replacement and session revocation, additive SQLite migration, tests, and application-boundary documentation.

Exported from reviewed private source b8fb4ff3cd012859f2d307dfb2a1cc783a38f6db after trusted CI run 257 and exact Go 1.26.6 verification.

Material implementation assistance provided by OpenAI Codex; reviewed and verified through the maintainer workflow.

Signed-off-by: Cole Speelman <crspeelman@gmail.com>
This commit is contained in:
2026-08-18 00:08:01 -04:00
parent 920e68f57f
commit 5905fe6fb2
10 changed files with 282 additions and 9 deletions
+53 -2
View File
@@ -21,6 +21,7 @@ import (
var (
ErrInvalidCredentials = errors.New("auth: invalid credentials")
ErrInactiveUser = errors.New("auth: account is not active")
ErrPasswordUnchanged = errors.New("auth: new password must differ from the current password")
ErrSessionNotFound = errors.New("auth: session not found")
ErrUserNotFound = errors.New("auth: user not found")
identifierPattern = regexp.MustCompile(`^[A-Za-z0-9][A-Za-z0-9_.-]{2,63}$`)
@@ -29,6 +30,7 @@ var (
type User struct {
ID, Username, Email, DisplayName, Status string
CreatedAt, UpdatedAt time.Time
PasswordChangeRequired bool
}
type Principal struct {
@@ -59,6 +61,8 @@ type PolicySeed struct {
type Repository interface {
CreateUser(context.Context, User, string) error
CredentialByIdentifier(context.Context, string) (User, string, error)
CredentialByUserID(context.Context, string) (User, string, error)
ReplacePasswordAndRevokeSessions(context.Context, string, string, string, time.Time) error
UpdateLastLogin(context.Context, string, time.Time) error
CreateSession(context.Context, Session) error
PrincipalBySession(context.Context, [32]byte, time.Time) (Principal, Session, error)
@@ -102,7 +106,10 @@ func New(repository Repository, options Options) (*Service, error) {
return &Service{repository: repository, random: options.Random, now: options.Now, touchInterval: options.TouchInterval}, nil
}
type CreateUser struct{ Username, Email, DisplayName, Password string }
type CreateUser struct {
Username, Email, DisplayName, Password string
RequirePasswordChange bool
}
func (service *Service) CreateUser(ctx context.Context, input CreateUser) (User, error) {
username := strings.TrimSpace(input.Username)
@@ -120,13 +127,57 @@ func (service *Service) CreateUser(ctx context.Context, input CreateUser) (User,
return User{}, err
}
now := service.now().UTC()
user := User{ID: id, Username: username, Email: email, DisplayName: displayName, Status: "active", CreatedAt: now, UpdatedAt: now}
user := User{ID: id, Username: username, Email: email, DisplayName: displayName, Status: "active", CreatedAt: now, UpdatedAt: now, PasswordChangeRequired: input.RequirePasswordChange}
if err = service.repository.CreateUser(ctx, user, hash); err != nil {
return User{}, err
}
return user, nil
}
// GenerateTemporaryPassword returns 256 bits of URL-safe cryptographic
// entropy suitable for an application-managed one-time bootstrap credential.
func GenerateTemporaryPassword(random io.Reader) (string, error) {
if random == nil {
random = rand.Reader
}
return randomToken(random, 32)
}
// ChangePassword verifies the current credential, rejects reuse, replaces the
// Argon2id hash, clears the password-change requirement, and revokes every
// existing session through one repository operation.
func (service *Service) ChangePassword(ctx context.Context, userID, currentPassword, newPassword string) error {
user, currentHash, err := service.repository.CredentialByUserID(ctx, strings.TrimSpace(userID))
if errors.Is(err, ErrUserNotFound) {
_ = VerifyPassword(dummyPasswordHash, currentPassword)
return ErrInvalidCredentials
}
if err != nil {
_ = VerifyPassword(dummyPasswordHash, currentPassword)
return fmt.Errorf("auth: load credentials: %w", err)
}
if !VerifyPassword(currentHash, currentPassword) {
return ErrInvalidCredentials
}
if user.Status != "active" {
return ErrInactiveUser
}
if currentPassword == newPassword {
return ErrPasswordUnchanged
}
newHash, err := HashPasswordWithRandom(newPassword, service.random)
if err != nil {
return err
}
if err = service.repository.ReplacePasswordAndRevokeSessions(ctx, user.ID, currentHash, newHash, service.now().UTC()); err != nil {
if errors.Is(err, ErrInvalidCredentials) {
return ErrInvalidCredentials
}
return fmt.Errorf("auth: replace password: %w", err)
}
return nil
}
func (service *Service) Authenticate(ctx context.Context, identifier, password string, lifetime time.Duration) (string, Principal, error) {
if lifetime < 5*time.Minute || lifetime > 30*24*time.Hour {
return "", Principal{}, errors.New("auth: invalid session lifetime")