Export the reviewed application-neutral package set through the exact public allowlist. Development history and private application evidence remain outside this canonical source root. Developed with material AI assistance under maintainer review. Signed-off-by: Cole Speelman <crspeelman@gmail.com>
This commit is contained in:
+224
@@ -0,0 +1,224 @@
|
||||
// SPDX-License-Identifier: MPL-2.0
|
||||
|
||||
// Package auth defines storage-neutral users, credentials, opaque sessions,
|
||||
// permissions, and audit events. Applications retain authorization policy.
|
||||
package auth
|
||||
|
||||
import (
|
||||
"context"
|
||||
"crypto/rand"
|
||||
"crypto/sha256"
|
||||
"encoding/base64"
|
||||
"errors"
|
||||
"fmt"
|
||||
"io"
|
||||
"regexp"
|
||||
"sort"
|
||||
"strings"
|
||||
"time"
|
||||
)
|
||||
|
||||
var (
|
||||
ErrInvalidCredentials = errors.New("auth: invalid credentials")
|
||||
ErrInactiveUser = errors.New("auth: account is not active")
|
||||
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}$`)
|
||||
)
|
||||
|
||||
type User struct {
|
||||
ID, Username, Email, DisplayName, Status string
|
||||
CreatedAt, UpdatedAt time.Time
|
||||
}
|
||||
|
||||
type Principal struct {
|
||||
User User
|
||||
Roles []string
|
||||
Permissions map[string]bool
|
||||
}
|
||||
|
||||
func (principal Principal) Has(permission string) bool { return principal.Permissions[permission] }
|
||||
|
||||
type Session struct {
|
||||
Digest [32]byte
|
||||
UserID string
|
||||
CreatedAt, ExpiresAt, LastSeenAt time.Time
|
||||
}
|
||||
|
||||
type AuditEvent struct {
|
||||
ID, ActorUserID, Action, ResourceType, ResourceID, RequestID, Summary string
|
||||
CreatedAt time.Time
|
||||
}
|
||||
|
||||
type PolicySeed struct {
|
||||
Roles map[string]string
|
||||
Permissions map[string]string
|
||||
RolePermissions map[string][]string
|
||||
}
|
||||
|
||||
type Repository interface {
|
||||
CreateUser(context.Context, User, string) error
|
||||
CredentialByIdentifier(context.Context, string) (User, string, error)
|
||||
UpdateLastLogin(context.Context, string, time.Time) error
|
||||
CreateSession(context.Context, Session) error
|
||||
PrincipalBySession(context.Context, [32]byte, time.Time) (Principal, Session, error)
|
||||
TouchSession(context.Context, [32]byte, time.Time) error
|
||||
DeleteSession(context.Context, [32]byte) error
|
||||
RevokeUserSessions(context.Context, string) error
|
||||
SeedPolicy(context.Context, PolicySeed) error
|
||||
GrantRole(context.Context, string, string, time.Time) error
|
||||
AppendAudit(context.Context, AuditEvent) error
|
||||
}
|
||||
|
||||
type Service struct {
|
||||
repository Repository
|
||||
random io.Reader
|
||||
now func() time.Time
|
||||
touchInterval time.Duration
|
||||
}
|
||||
|
||||
type Options struct {
|
||||
Random io.Reader
|
||||
Now func() time.Time
|
||||
TouchInterval time.Duration
|
||||
}
|
||||
|
||||
func New(repository Repository, options Options) (*Service, error) {
|
||||
if repository == nil {
|
||||
return nil, errors.New("auth: repository is required")
|
||||
}
|
||||
if options.Random == nil {
|
||||
options.Random = rand.Reader
|
||||
}
|
||||
if options.Now == nil {
|
||||
options.Now = time.Now
|
||||
}
|
||||
if options.TouchInterval == 0 {
|
||||
options.TouchInterval = 5 * time.Minute
|
||||
}
|
||||
if options.TouchInterval < time.Minute || options.TouchInterval > time.Hour {
|
||||
return nil, errors.New("auth: invalid session touch interval")
|
||||
}
|
||||
return &Service{repository: repository, random: options.Random, now: options.Now, touchInterval: options.TouchInterval}, nil
|
||||
}
|
||||
|
||||
type CreateUser struct{ Username, Email, DisplayName, Password string }
|
||||
|
||||
func (service *Service) CreateUser(ctx context.Context, input CreateUser) (User, error) {
|
||||
username := strings.TrimSpace(input.Username)
|
||||
email := strings.TrimSpace(input.Email)
|
||||
displayName := strings.TrimSpace(input.DisplayName)
|
||||
if !identifierPattern.MatchString(username) || email == "" || len(email) > 320 || !strings.Contains(email, "@") || displayName == "" || len(displayName) > 128 {
|
||||
return User{}, errors.New("auth: invalid user")
|
||||
}
|
||||
hash, err := HashPasswordWithRandom(input.Password, service.random)
|
||||
if err != nil {
|
||||
return User{}, err
|
||||
}
|
||||
id, err := randomToken(service.random, 18)
|
||||
if err != nil {
|
||||
return User{}, err
|
||||
}
|
||||
now := service.now().UTC()
|
||||
user := User{ID: id, Username: username, Email: email, DisplayName: displayName, Status: "active", CreatedAt: now, UpdatedAt: now}
|
||||
if err = service.repository.CreateUser(ctx, user, hash); err != nil {
|
||||
return User{}, err
|
||||
}
|
||||
return user, 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")
|
||||
}
|
||||
user, hash, err := service.repository.CredentialByIdentifier(ctx, strings.TrimSpace(identifier))
|
||||
if errors.Is(err, ErrUserNotFound) {
|
||||
_ = VerifyPassword(dummyPasswordHash, password)
|
||||
return "", Principal{}, ErrInvalidCredentials
|
||||
}
|
||||
if err != nil {
|
||||
_ = VerifyPassword(dummyPasswordHash, password)
|
||||
return "", Principal{}, fmt.Errorf("auth: load credentials: %w", err)
|
||||
}
|
||||
if !VerifyPassword(hash, password) {
|
||||
return "", Principal{}, ErrInvalidCredentials
|
||||
}
|
||||
if user.Status != "active" {
|
||||
return "", Principal{}, ErrInactiveUser
|
||||
}
|
||||
token, err := randomToken(service.random, 32)
|
||||
if err != nil {
|
||||
return "", Principal{}, err
|
||||
}
|
||||
now := service.now().UTC()
|
||||
digest := sha256.Sum256([]byte(token))
|
||||
if err = service.repository.CreateSession(ctx, Session{Digest: digest, UserID: user.ID, CreatedAt: now, ExpiresAt: now.Add(lifetime), LastSeenAt: now}); err != nil {
|
||||
return "", Principal{}, err
|
||||
}
|
||||
_ = service.repository.UpdateLastLogin(ctx, user.ID, now)
|
||||
principal, _, err := service.repository.PrincipalBySession(ctx, digest, now)
|
||||
if err != nil {
|
||||
_ = service.repository.DeleteSession(ctx, digest)
|
||||
return "", Principal{}, err
|
||||
}
|
||||
return token, principal, nil
|
||||
}
|
||||
|
||||
func (service *Service) Session(ctx context.Context, token string) (Principal, error) {
|
||||
if len(token) < 32 || len(token) > 128 {
|
||||
return Principal{}, ErrSessionNotFound
|
||||
}
|
||||
digest := sha256.Sum256([]byte(token))
|
||||
now := service.now().UTC()
|
||||
principal, session, err := service.repository.PrincipalBySession(ctx, digest, now)
|
||||
if errors.Is(err, ErrSessionNotFound) {
|
||||
return Principal{}, ErrSessionNotFound
|
||||
}
|
||||
if err != nil {
|
||||
return Principal{}, fmt.Errorf("auth: load session: %w", err)
|
||||
}
|
||||
if principal.User.Status != "active" {
|
||||
_ = service.repository.DeleteSession(ctx, digest)
|
||||
return Principal{}, ErrInactiveUser
|
||||
}
|
||||
if now.Sub(session.LastSeenAt) >= service.touchInterval {
|
||||
_ = service.repository.TouchSession(ctx, digest, now)
|
||||
}
|
||||
principal.Roles = sortedUnique(principal.Roles)
|
||||
if principal.Permissions == nil {
|
||||
principal.Permissions = map[string]bool{}
|
||||
}
|
||||
return principal, nil
|
||||
}
|
||||
|
||||
func (service *Service) RevokeSession(ctx context.Context, token string) error {
|
||||
digest := sha256.Sum256([]byte(token))
|
||||
return service.repository.DeleteSession(ctx, digest)
|
||||
}
|
||||
func (service *Service) RevokeUserSessions(ctx context.Context, userID string) error {
|
||||
return service.repository.RevokeUserSessions(ctx, userID)
|
||||
}
|
||||
func (service *Service) Repository() Repository { return service.repository }
|
||||
|
||||
func randomToken(random io.Reader, bytes int) (string, error) {
|
||||
value := make([]byte, bytes)
|
||||
if _, err := io.ReadFull(random, value); err != nil {
|
||||
return "", fmt.Errorf("auth: secure randomness unavailable: %w", err)
|
||||
}
|
||||
return base64.RawURLEncoding.EncodeToString(value), nil
|
||||
}
|
||||
|
||||
func sortedUnique(values []string) []string {
|
||||
set := make(map[string]struct{}, len(values))
|
||||
for _, value := range values {
|
||||
if value != "" {
|
||||
set[value] = struct{}{}
|
||||
}
|
||||
}
|
||||
result := make([]string, 0, len(set))
|
||||
for value := range set {
|
||||
result = append(result, value)
|
||||
}
|
||||
sort.Strings(result)
|
||||
return result
|
||||
}
|
||||
@@ -0,0 +1,15 @@
|
||||
// SPDX-License-Identifier: MPL-2.0
|
||||
|
||||
package auth
|
||||
|
||||
import "context"
|
||||
|
||||
type principalKey struct{}
|
||||
|
||||
func WithPrincipal(ctx context.Context, principal Principal) context.Context {
|
||||
return context.WithValue(ctx, principalKey{}, principal)
|
||||
}
|
||||
func PrincipalFromContext(ctx context.Context) (Principal, bool) {
|
||||
principal, ok := ctx.Value(principalKey{}).(Principal)
|
||||
return principal, ok
|
||||
}
|
||||
@@ -0,0 +1,92 @@
|
||||
// SPDX-License-Identifier: MPL-2.0
|
||||
|
||||
package auth
|
||||
|
||||
import (
|
||||
"crypto/rand"
|
||||
"crypto/subtle"
|
||||
"encoding/base64"
|
||||
"errors"
|
||||
"fmt"
|
||||
"io"
|
||||
"strconv"
|
||||
"strings"
|
||||
|
||||
"golang.org/x/crypto/argon2"
|
||||
)
|
||||
|
||||
const (
|
||||
passwordMemory = 32 * 1024
|
||||
passwordTime = 3
|
||||
passwordThreads = 1
|
||||
passwordKeyLen = 32
|
||||
passwordSaltLen = 16
|
||||
)
|
||||
|
||||
func ValidatePassword(password string) error {
|
||||
if len(password) < 12 {
|
||||
return errors.New("auth: password must contain at least 12 characters")
|
||||
}
|
||||
if len(password) > 1024 {
|
||||
return errors.New("auth: password is too long")
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func HashPassword(password string) (string, error) {
|
||||
return HashPasswordWithRandom(password, rand.Reader)
|
||||
}
|
||||
|
||||
func HashPasswordWithRandom(password string, random io.Reader) (string, error) {
|
||||
if err := ValidatePassword(password); err != nil {
|
||||
return "", err
|
||||
}
|
||||
if random == nil {
|
||||
return "", errors.New("auth: password entropy source is nil")
|
||||
}
|
||||
salt := make([]byte, passwordSaltLen)
|
||||
if _, err := io.ReadFull(random, salt); err != nil {
|
||||
return "", fmt.Errorf("auth: generate password salt: %w", err)
|
||||
}
|
||||
return encodePassword(password, salt, passwordTime, passwordMemory, passwordThreads, passwordKeyLen), nil
|
||||
}
|
||||
|
||||
func VerifyPassword(encoded, password string) bool {
|
||||
parts := strings.Split(encoded, "$")
|
||||
if len(parts) != 6 || parts[1] != "argon2id" || parts[2] != "v=19" {
|
||||
return false
|
||||
}
|
||||
parameters := map[string]uint64{}
|
||||
for _, value := range strings.Split(parts[3], ",") {
|
||||
pair := strings.SplitN(value, "=", 2)
|
||||
if len(pair) != 2 {
|
||||
return false
|
||||
}
|
||||
parsed, err := strconv.ParseUint(pair[1], 10, 32)
|
||||
if err != nil {
|
||||
return false
|
||||
}
|
||||
parameters[pair[0]] = parsed
|
||||
}
|
||||
memory, iterations, threads := parameters["m"], parameters["t"], parameters["p"]
|
||||
if len(parameters) != 3 || memory < 8*1024 || memory > 256*1024 || iterations < 1 || iterations > 10 || threads < 1 || threads > 16 {
|
||||
return false
|
||||
}
|
||||
salt, err := base64.RawStdEncoding.DecodeString(parts[4])
|
||||
if err != nil || len(salt) < 8 || len(salt) > 64 {
|
||||
return false
|
||||
}
|
||||
want, err := base64.RawStdEncoding.DecodeString(parts[5])
|
||||
if err != nil || len(want) < 16 || len(want) > 64 {
|
||||
return false
|
||||
}
|
||||
got := argon2.IDKey([]byte(password), salt, uint32(iterations), uint32(memory), uint8(threads), uint32(len(want)))
|
||||
return subtle.ConstantTimeCompare(got, want) == 1
|
||||
}
|
||||
|
||||
func encodePassword(password string, salt []byte, iterations, memory uint32, threads uint8, keyLen uint32) string {
|
||||
hash := argon2.IDKey([]byte(password), salt, iterations, memory, threads, keyLen)
|
||||
return fmt.Sprintf("$argon2id$v=19$m=%d,t=%d,p=%d$%s$%s", memory, iterations, threads, base64.RawStdEncoding.EncodeToString(salt), base64.RawStdEncoding.EncodeToString(hash))
|
||||
}
|
||||
|
||||
var dummyPasswordHash = encodePassword("this-account-does-not-exist", []byte("gamertan-web-dummy-salt"), passwordTime, passwordMemory, passwordThreads, passwordKeyLen)
|
||||
@@ -0,0 +1,33 @@
|
||||
// SPDX-License-Identifier: MPL-2.0
|
||||
|
||||
package auth
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestPasswordRoundTripAndBounds(t *testing.T) {
|
||||
hash, err := HashPasswordWithRandom("correct horse battery staple", strings.NewReader(strings.Repeat("s", 16)))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if !VerifyPassword(hash, "correct horse battery staple") || VerifyPassword(hash, "wrong password") {
|
||||
t.Fatal("password verification mismatch")
|
||||
}
|
||||
if err = ValidatePassword("short"); err == nil {
|
||||
t.Fatal("short password accepted")
|
||||
}
|
||||
}
|
||||
|
||||
func TestPasswordEntropyFailsClosed(t *testing.T) {
|
||||
_, err := HashPasswordWithRandom("correct horse battery staple", errorReader{})
|
||||
if err == nil {
|
||||
t.Fatal("entropy failure accepted")
|
||||
}
|
||||
}
|
||||
|
||||
type errorReader struct{}
|
||||
|
||||
func (errorReader) Read([]byte) (int, error) { return 0, errors.New("no entropy") }
|
||||
@@ -0,0 +1,50 @@
|
||||
// SPDX-License-Identifier: MPL-2.0
|
||||
|
||||
package auth
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
|
||||
func TestSessionDistinguishesMissingFromUnavailableStorage(t *testing.T) {
|
||||
service, err := New(repositoryStub{sessionErr: ErrSessionNotFound}, Options{})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if _, err = service.Session(t.Context(), strings.Repeat("x", 43)); !errors.Is(err, ErrSessionNotFound) {
|
||||
t.Fatalf("missing err=%v", err)
|
||||
}
|
||||
|
||||
storageErr := errors.New("storage offline")
|
||||
service, err = New(repositoryStub{sessionErr: storageErr}, Options{})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if _, err = service.Session(t.Context(), strings.Repeat("x", 43)); !errors.Is(err, storageErr) || errors.Is(err, ErrSessionNotFound) {
|
||||
t.Fatalf("storage err=%v", err)
|
||||
}
|
||||
}
|
||||
|
||||
type repositoryStub struct{ sessionErr error }
|
||||
|
||||
func (repositoryStub) CreateUser(context.Context, User, string) error { return nil }
|
||||
func (repositoryStub) CredentialByIdentifier(context.Context, string) (User, string, error) {
|
||||
return User{}, "", ErrUserNotFound
|
||||
}
|
||||
func (repositoryStub) UpdateLastLogin(context.Context, string, time.Time) error { return nil }
|
||||
func (repositoryStub) CreateSession(context.Context, Session) error { return nil }
|
||||
func (repository repositoryStub) PrincipalBySession(context.Context, [32]byte, time.Time) (Principal, Session, error) {
|
||||
return Principal{}, Session{}, repository.sessionErr
|
||||
}
|
||||
func (repositoryStub) TouchSession(context.Context, [32]byte, time.Time) error { return nil }
|
||||
func (repositoryStub) DeleteSession(context.Context, [32]byte) error { return nil }
|
||||
func (repositoryStub) RevokeUserSessions(context.Context, string) error { return nil }
|
||||
func (repositoryStub) SeedPolicy(context.Context, PolicySeed) error { return nil }
|
||||
func (repositoryStub) GrantRole(context.Context, string, string, time.Time) error {
|
||||
return nil
|
||||
}
|
||||
func (repositoryStub) AppendAudit(context.Context, AuditEvent) error { return nil }
|
||||
Reference in New Issue
Block a user